Files
RenewCMS/web/visual/localizationStates.ts
T

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 })