514 lines
15 KiB
TypeScript
514 lines
15 KiB
TypeScript
import type { Page } from 'playwright'
|
|
import { accessFor } from '../src/test/access'
|
|
import type { ApiMock, VisualState } from './states'
|
|
|
|
type Lang = {
|
|
lang: string
|
|
enabled?: boolean
|
|
versions: number
|
|
published: number
|
|
social?: string
|
|
derived?: boolean
|
|
isDefault?: boolean
|
|
}
|
|
|
|
const SUPPORTED = ['en', 'en-GB', 'en-US', 'fr', 'fr-CA', 'pt', 'pt-BR', 'es', 'de', 'ar']
|
|
const AVAILABLE = [
|
|
...SUPPORTED.map((lang) => ({ lang, substitute: null })),
|
|
{ lang: 'pt-PT', substitute: 'pt' },
|
|
]
|
|
|
|
const FRENCH_TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
|
|
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
|
|
|
|
function languagesBody(revision: string, def: string, rows: Lang[]) {
|
|
return {
|
|
revision,
|
|
default: def,
|
|
needs_setup: false,
|
|
languages: rows.map((row) => ({
|
|
lang: row.lang,
|
|
enabled: row.enabled ?? true,
|
|
is_default: row.lang === def,
|
|
version_count: row.versions,
|
|
published_count: row.published,
|
|
social_locale: row.social ?? null,
|
|
social_locale_state: row.social ? 'set' : row.derived ? 'derived' : 'not_set',
|
|
})),
|
|
available: AVAILABLE,
|
|
}
|
|
}
|
|
|
|
const journalRows: Lang[] = [
|
|
{ lang: 'en', versions: 7, published: 6 },
|
|
{ lang: 'fr', versions: 4, published: 2, social: 'fr_FR' },
|
|
{ lang: 'de', versions: 4, published: 3 },
|
|
{ lang: 'ar', versions: 1, published: 0 },
|
|
{ lang: 'es', enabled: false, versions: 1, published: 1 },
|
|
{ lang: 'it', enabled: false, versions: 0, published: 0 },
|
|
]
|
|
const journal = languagesBody('journal-1', 'en', journalRows)
|
|
const journalWithoutFrench = languagesBody(
|
|
'journal-0',
|
|
'en',
|
|
journalRows.filter((row) => row.lang !== 'fr'),
|
|
)
|
|
const atelier = languagesBody('atelier-1', 'en', [
|
|
{ lang: 'en', versions: 3, published: 2 },
|
|
{ lang: 'en-GB', versions: 3, published: 1, derived: true },
|
|
{ lang: 'fr', versions: 2, published: 0, social: 'fr_FR' },
|
|
{ lang: 'de', versions: 2, published: 2 },
|
|
{ lang: 'pt-BR', versions: 1, published: 0, derived: true },
|
|
{ lang: 'zh-Hans', versions: 1, published: 1 },
|
|
{ lang: 'ja', versions: 2, published: 1 },
|
|
{ lang: 'ar', versions: 1, published: 0 },
|
|
])
|
|
const archive = languagesBody('archive-1', 'pt-BR', [
|
|
{ lang: 'pt-BR', versions: 5, published: 4, derived: true },
|
|
])
|
|
|
|
function site(id: number, key: string, name: string, extra: Record<string, unknown> = {}) {
|
|
return {
|
|
id,
|
|
name,
|
|
key,
|
|
site_name: name,
|
|
title_template: '%title% | %site%',
|
|
default_description: '',
|
|
base_url: `https://${key}.example.com`,
|
|
default_social_image_id: null,
|
|
default_robots: 'index',
|
|
locale: null,
|
|
is_default: id === 1,
|
|
article_count: 0,
|
|
public_url_pattern: null,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
const sites = (patternOnJournal: string | null = null) => [
|
|
site(1, 'journal', 'Journal', {
|
|
locale: 'en',
|
|
article_count: 8,
|
|
public_url_pattern: patternOnJournal,
|
|
}),
|
|
site(2, 'atelier', 'Atelier', { locale: 'en', article_count: 3 }),
|
|
site(3, 'archive', 'Archive', { locale: 'pt-BR', article_count: 5 }),
|
|
site(4, 'notes', 'Notes', { article_count: 3 }),
|
|
site(5, 'gallery', 'Gallery', { locale: 'en-GB' }),
|
|
]
|
|
|
|
function session(role: 'owner' | 'viewer'): ApiMock {
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
id: 1,
|
|
is_verified: true,
|
|
username: 'marta',
|
|
email: 'marta@renew.example',
|
|
display_name: 'Marta Alves',
|
|
access: accessFor(role),
|
|
},
|
|
}
|
|
}
|
|
|
|
const ok = (body: unknown): ApiMock => ({ status: 200, body })
|
|
|
|
function languagesApi(
|
|
key: string,
|
|
body: unknown,
|
|
options: { role?: 'owner' | 'viewer'; put?: ApiMock; get?: ApiMock } = {},
|
|
): Record<string, ApiMock> {
|
|
return {
|
|
'GET /v1/auth/me': session(options.role ?? 'owner'),
|
|
'GET /v1/site/': ok(sites()),
|
|
[`GET /v1/site/${key}/languages`]: options.get ?? ok(body),
|
|
...(options.put ? { [`PUT /v1/site/${key}/languages`]: options.put } : {}),
|
|
}
|
|
}
|
|
|
|
const languagesPath = (key: string) => `/settings/sites/${key}/languages`
|
|
|
|
async function openActions(page: Page, name: string) {
|
|
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
|
|
await page.getByRole('menuitem').first().waitFor()
|
|
}
|
|
|
|
async function chooseAction(page: Page, name: string, item: string) {
|
|
await openActions(page, name)
|
|
await page.getByRole('menuitem', { name: item, exact: true }).click()
|
|
await page
|
|
.getByRole('dialog')
|
|
.waitFor()
|
|
.catch(() => undefined)
|
|
await page.mouse.move(0, 0)
|
|
}
|
|
|
|
async function pick(page: Page, label: string, option: string) {
|
|
await page.getByRole('combobox', { name: label, exact: true }).click()
|
|
await page.getByRole('option', { name: option, exact: true }).click()
|
|
}
|
|
|
|
async function openAdd(page: Page) {
|
|
await page.getByRole('button', { name: 'Add language', exact: true }).first().click()
|
|
await page.getByRole('dialog').waitFor()
|
|
}
|
|
|
|
async function addLanguage(page: Page, option: string) {
|
|
await openAdd(page)
|
|
await pick(page, 'Language', option)
|
|
await page.mouse.move(0, 0)
|
|
}
|
|
|
|
async function confirm(page: Page, label: string) {
|
|
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
|
|
}
|
|
|
|
const putFails: ApiMock = { status: 500, body: 'Internal server error' }
|
|
|
|
function languagesState(
|
|
key: string,
|
|
body: unknown,
|
|
steps?: (page: Page) => Promise<void>,
|
|
options: { role?: 'owner' | 'viewer'; put?: ApiMock; get?: ApiMock } = {},
|
|
): VisualState {
|
|
return { path: languagesPath(key), api: languagesApi(key, body, options), steps }
|
|
}
|
|
|
|
const l1: Record<string, VisualState> = {
|
|
'loc.l1.single': languagesState('archive', archive),
|
|
'loc.l1.multi': languagesState('journal', journal),
|
|
'loc.l1.loading': languagesState('journal', journal, undefined, {
|
|
get: { status: 200, hang: true },
|
|
}),
|
|
'loc.l1.error': languagesState('journal', journal, undefined, {
|
|
get: { status: 500, body: 'Internal server error' },
|
|
}),
|
|
'loc.l1.add': languagesState('journal', journal, (page) =>
|
|
addLanguage(page, 'Portuguese (Brazil)'),
|
|
),
|
|
'loc.l1.saveFail': languagesState(
|
|
'journal',
|
|
journal,
|
|
async (page) => {
|
|
await addLanguage(page, 'Portuguese (Brazil)')
|
|
await confirm(page, 'Add language')
|
|
await page.getByText("Couldn't save languages").waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
{ put: putFails },
|
|
),
|
|
'loc.l1.adddup': languagesState('journal', journal, (page) => addLanguage(page, 'French')),
|
|
'loc.l1.broader': languagesState('journal', journal, async (page) => {
|
|
await addLanguage(page, 'Portuguese (Portugal)')
|
|
await page.getByRole('checkbox').check()
|
|
await page.mouse.move(0, 0)
|
|
}),
|
|
'loc.l1.default': languagesState('journal', journal, (page) =>
|
|
chooseAction(page, 'French', 'Make default'),
|
|
),
|
|
'loc.l1.defaultFail': languagesState(
|
|
'journal',
|
|
journal,
|
|
async (page) => {
|
|
await chooseAction(page, 'French', 'Make default')
|
|
await confirm(page, 'Make French default')
|
|
await page
|
|
.getByRole('dialog')
|
|
.getByText("Couldn't change the default language")
|
|
.waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
{ put: putFails },
|
|
),
|
|
'loc.l1.disable': languagesState('journal', journal, (page) =>
|
|
chooseAction(page, 'German', 'Disable'),
|
|
),
|
|
'loc.l1.stale': languagesState(
|
|
'journal',
|
|
journal,
|
|
async (page) => {
|
|
await chooseAction(page, 'German', 'Disable')
|
|
await confirm(page, 'Disable German')
|
|
await page.getByRole('dialog').getByText('Languages changed. Reload.').waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
{ put: { status: 409, body: 'languages_changed' } },
|
|
),
|
|
'loc.lang.disableDefault': languagesState('journal', journal, (page) =>
|
|
chooseAction(page, 'English', 'Disable'),
|
|
),
|
|
'loc.lang.disableLast': languagesState(
|
|
'journal',
|
|
languagesBody('journal-solo', 'en', [
|
|
{ lang: 'en', versions: 7, published: 6 },
|
|
...journalRows.slice(1).map((row) => ({ ...row, enabled: false })),
|
|
]),
|
|
(page) => chooseAction(page, 'English', 'Disable'),
|
|
),
|
|
'loc.l1.reenable': languagesState('journal', journal, async (page) => {
|
|
await page.getByRole('button', { name: 'Re-enable', exact: true }).first().click()
|
|
await page.getByRole('dialog').waitFor()
|
|
await page.mouse.move(0, 0)
|
|
}),
|
|
'loc.l1.reenableDone': languagesState(
|
|
'journal',
|
|
journal,
|
|
async (page) => {
|
|
await page.getByRole('button', { name: 'Re-enable', exact: true }).first().click()
|
|
await confirm(page, 'Re-enable Spanish')
|
|
await page.getByText('Spanish re-enabled', { exact: true }).waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
{ put: ok(journal) },
|
|
),
|
|
'loc.l1.reenableFail': languagesState(
|
|
'journal',
|
|
journal,
|
|
async (page) => {
|
|
await page.getByRole('button', { name: 'Re-enable', exact: true }).first().click()
|
|
await confirm(page, 'Re-enable Spanish')
|
|
await page.getByText("Couldn't re-enable Spanish").waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
{ put: putFails },
|
|
),
|
|
'loc.l1.remove': languagesState('journal', journal, (page) =>
|
|
chooseAction(page, 'Italian', 'Remove'),
|
|
),
|
|
'loc.l1.removeFail': languagesState(
|
|
'journal',
|
|
journal,
|
|
async (page) => {
|
|
await chooseAction(page, 'Italian', 'Remove')
|
|
await confirm(page, 'Remove Italian')
|
|
await page.getByRole('dialog').getByText("Couldn't remove Italian").waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
{ put: putFails },
|
|
),
|
|
'loc.l1.removeBlocked': languagesState('journal', journal, (page) =>
|
|
chooseAction(page, 'Spanish', 'Remove'),
|
|
),
|
|
'loc.l1.readonly': languagesState('journal', journal, undefined, { role: 'viewer' }),
|
|
'loc.l1.saved': {
|
|
path: languagesPath('journal'),
|
|
api: {
|
|
...languagesApi('journal', journal, { put: ok(journal) }),
|
|
'GET /v1/site/journal/languages': {
|
|
status: 200,
|
|
bodyFor: (_url, call) => (call === 1 ? journalWithoutFrench : journal),
|
|
},
|
|
},
|
|
steps: async (page) => {
|
|
await addLanguage(page, 'French')
|
|
await confirm(page, 'Add language')
|
|
await page.getByText('French added', { exact: true }).waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
},
|
|
'loc.l1.social': languagesState('atelier', atelier),
|
|
'loc.l1.socialEdit': languagesState('atelier', atelier, async (page) => {
|
|
await page.getByRole('button', { name: 'Edit social locale for French' }).click()
|
|
await page.getByRole('dialog').waitFor()
|
|
await page.mouse.move(0, 0)
|
|
}),
|
|
'loc.l1.socialInvalid': languagesState('atelier', atelier, async (page) => {
|
|
await page.getByRole('button', { name: 'Edit social locale for French' }).click()
|
|
await page.getByRole('dialog').getByRole('textbox').fill('fr-FR')
|
|
await page.mouse.move(0, 0)
|
|
}),
|
|
}
|
|
|
|
const articlePage = ok({
|
|
items: [
|
|
{
|
|
id: 14,
|
|
title: FRENCH_TITLE,
|
|
slug: 'comment-nous-chiffrons-une-renovation',
|
|
lang: 'fr',
|
|
body: '',
|
|
is_online: true,
|
|
created_at: '2026-10-31T10:00:00Z',
|
|
updated_at: '2026-10-31T10:00:00Z',
|
|
},
|
|
],
|
|
total: 1,
|
|
page: 1,
|
|
size: 1,
|
|
counts: { all: 1, online: 1, draft: 0 },
|
|
})
|
|
|
|
function publicUrlState(
|
|
steps?: (page: Page) => Promise<void>,
|
|
options: { role?: 'owner' | 'viewer'; pattern?: string | null; patch?: ApiMock } = {},
|
|
): VisualState {
|
|
const pattern = options.pattern === undefined ? PATTERN : options.pattern
|
|
return {
|
|
path: '/settings/sites/journal/public-url',
|
|
api: {
|
|
'GET /v1/auth/me': session(options.role ?? 'owner'),
|
|
'GET /v1/site/': ok(sites(pattern)),
|
|
'GET /v1/article/': articlePage,
|
|
...(options.patch ? { 'PATCH /v1/site/1': options.patch } : {}),
|
|
},
|
|
steps,
|
|
}
|
|
}
|
|
|
|
async function typePattern(page: Page, value: string) {
|
|
await page.getByRole('textbox', { name: 'Public URL pattern' }).fill(value)
|
|
await page.mouse.move(0, 0)
|
|
}
|
|
|
|
const l1b: Record<string, VisualState> = {
|
|
'loc.l1b.set': publicUrlState(),
|
|
'loc.l1b.editing': publicUrlState((page) =>
|
|
typePattern(page, 'https://journal.example.com/{lang}/articles/{slug}'),
|
|
),
|
|
'loc.l1b.unknown': publicUrlState((page) =>
|
|
typePattern(page, 'https://journal.example.com/{language}/{slug}'),
|
|
),
|
|
'loc.l1b.scheme': publicUrlState((page) =>
|
|
typePattern(page, 'ftp://journal.example.com/{lang}/{slug}'),
|
|
),
|
|
'loc.l1b.encoded': publicUrlState((page) =>
|
|
typePattern(page, 'https://journal.example.com/my pages/{lang}/{slug}'),
|
|
),
|
|
'loc.l1b.noSlug': publicUrlState((page) =>
|
|
typePattern(page, 'https://journal.example.com/{lang}'),
|
|
),
|
|
'loc.l1b.saved': publicUrlState(
|
|
async (page) => {
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
|
await page.getByText('Public URL saved', { exact: true }).waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
{ patch: ok(sites(PATTERN)[0]) },
|
|
),
|
|
'loc.l1b.unset': publicUrlState(undefined, { pattern: null }),
|
|
'loc.l1b.viewOnly': publicUrlState(undefined, { role: 'viewer' }),
|
|
}
|
|
|
|
const setupSites = {
|
|
journal: {
|
|
key: 'journal',
|
|
name: 'Journal',
|
|
locale: 'en',
|
|
article_count: 8,
|
|
suggestion: { lang: 'en', substituted: false },
|
|
},
|
|
notes: { key: 'notes', name: 'Notes', locale: null, article_count: 3, suggestion: null },
|
|
archive: {
|
|
key: 'archive',
|
|
name: 'Archive',
|
|
locale: 'pt-BR',
|
|
article_count: 5,
|
|
suggestion: { lang: 'pt-BR', substituted: false },
|
|
},
|
|
gallery: {
|
|
key: 'gallery',
|
|
name: 'Gallery',
|
|
locale: 'en-GB',
|
|
article_count: 0,
|
|
suggestion: { lang: 'en-GB', substituted: false },
|
|
},
|
|
}
|
|
|
|
function setupApi(pending: (keyof typeof setupSites)[], post?: ApiMock): Record<string, ApiMock> {
|
|
return {
|
|
'GET /v1/auth/me': session('owner'),
|
|
'GET /v1/site/': ok(sites()),
|
|
'GET /v1/language/setup': ok({
|
|
sites: pending.map((key) => setupSites[key]),
|
|
available: AVAILABLE,
|
|
}),
|
|
...(post ? { 'POST /v1/language/setup': post } : {}),
|
|
}
|
|
}
|
|
|
|
const setupPath = '/settings/sites/journal/languages/setup'
|
|
const allPending: (keyof typeof setupSites)[] = ['journal', 'notes', 'archive']
|
|
|
|
async function chooseForNotes(page: Page) {
|
|
await pick(page, 'Language for Notes', 'English · en')
|
|
}
|
|
|
|
async function confirmSetup(page: Page) {
|
|
await page.getByRole('button', { name: /^Confirm and assign to/ }).click()
|
|
}
|
|
|
|
const l2: Record<string, VisualState> = {
|
|
'loc.l2.ready': { path: setupPath, api: setupApi(allPending) },
|
|
'loc.l2.noLocale': { path: setupPath, api: setupApi(['notes']) },
|
|
'loc.l2.empty': { path: setupPath, api: setupApi(['gallery']) },
|
|
'loc.l2.applying': {
|
|
path: setupPath,
|
|
api: setupApi(allPending, { status: 200, hang: true }),
|
|
steps: async (page) => {
|
|
await chooseForNotes(page)
|
|
await confirmSetup(page)
|
|
await page.getByText('Assigning languages').waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
},
|
|
'loc.l2.inProgress': {
|
|
path: setupPath,
|
|
api: setupApi(allPending, { status: 409, body: 'setup_in_progress' }),
|
|
steps: async (page) => {
|
|
await chooseForNotes(page)
|
|
await confirmSetup(page)
|
|
await page.getByText('Setup is already running').waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
},
|
|
'loc.l2.done': {
|
|
path: setupPath,
|
|
api: setupApi(allPending, ok({ assigned: 16, sites: [] })),
|
|
steps: async (page) => {
|
|
await chooseForNotes(page)
|
|
await confirmSetup(page)
|
|
await page.getByText('Languages assigned', { exact: true }).waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
},
|
|
'loc.l2.fail': {
|
|
path: setupPath,
|
|
api: setupApi(allPending, putFails),
|
|
steps: async (page) => {
|
|
await chooseForNotes(page)
|
|
await confirmSetup(page)
|
|
await page.getByText('Nothing was changed', { exact: true }).waitFor()
|
|
await page.mouse.move(0, 0)
|
|
},
|
|
},
|
|
}
|
|
|
|
async function rewind(page: Page) {
|
|
await page.evaluate(() => {
|
|
for (const element of document.querySelectorAll<HTMLElement>('*')) {
|
|
if (element.scrollTop > 0) {
|
|
element.scrollTop = 0
|
|
}
|
|
}
|
|
})
|
|
await page.mouse.move(0, 0)
|
|
}
|
|
|
|
function rewound(states: Record<string, VisualState>) {
|
|
return Object.fromEntries(
|
|
Object.entries(states).map(([id, state]) => [
|
|
id,
|
|
{
|
|
...state,
|
|
steps: async (page: Page) => {
|
|
await state.steps?.(page)
|
|
await rewind(page)
|
|
},
|
|
},
|
|
]),
|
|
)
|
|
}
|
|
|
|
export const localizationStates: Record<string, VisualState> = rewound({ ...l1, ...l1b, ...l2 })
|