feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

@@ -0,0 +1,112 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { visualStates } from '../states'
import {
localizationSizeLabel,
parseLocalizationScreen,
parseSelector,
parseSizes,
} from '../localizationScreen'
const designRoot = resolve(import.meta.dirname, '..', '..', '..', 'docs', 'design')
const screen = parseLocalizationScreen(
readFileSync(join(designRoot, 'screens', 'localization-site-languages.md'), 'utf8'),
)
function ids(label: string) {
return screen.groups
.find((group) => group.label.startsWith(`${label} `))!
.states.map((s) => s.id)
}
describe('Localization groups are discovered from the screen file', () => {
it('reads the board named by the reference line', () => {
expect(screen.board).toBe('RenewCMS Screens Localization.dc.html')
})
it('finds the groups L1, L1b and L2 with 25, 9 and 7 states', () => {
expect(screen.groups.map((group) => group.label)).toEqual([
'L1 Site languages',
'L1b Public URL pattern (owner setting)',
'L2 First-time setup for existing articles',
])
expect(screen.groups.map((group) => group.states.length)).toEqual([25, 9, 7])
})
it('lists the states by their loc ids', () => {
expect(ids('L1').slice(0, 3)).toEqual(['loc.l1.single', 'loc.l1.multi', 'loc.l1.loading'])
expect(ids('L1b')[0]).toBe('loc.l1b.set')
expect(ids('L2').at(-1)).toBe('loc.l2.fail')
expect(ids('L1')).toContain('loc.lang.disableDefault')
})
it('has no duplicate id', () => {
const every = screen.groups.flatMap((group) => group.states.map((state) => state.id))
expect(new Set(every).size).toBe(41)
})
})
describe('Capture sizes', () => {
it.each([
['1440, 390', [1440, 390]],
['1440, 1024, 390', [1440, 1024, 390]],
])('parses %s', (cell, expected) => {
expect(parseSizes(cell)).toEqual(expected)
})
it('gives a 1024 capture to loc.l1.multi and loc.l1.social only', () => {
const wide = screen.groups
.flatMap((group) => group.states)
.filter((state) => state.sizes.includes(1024))
.map((state) => state.id)
expect(wide).toEqual(['loc.l1.multi', 'loc.l1.social'])
})
it('names the viewports', () => {
expect([1440, 1024, 390].map(localizationSizeLabel)).toEqual([
'desktop',
'tablet',
'mobile',
])
})
})
describe('Selecting frames', () => {
it('maps an id to its LocaleFrame selector and fixture', () => {
const multi = screen.groups[0]!.states.find((state) => state.id === 'loc.l1.multi')!
expect(multi.selector).toEqual({ v: 'sl', x: 'multi' })
expect(multi.fixture).toBe('Journal')
expect(multi.name).toBe('Multilingual')
const setup = screen.groups[2]!.states.find((state) => state.id === 'loc.l2.ready')!
expect(setup.selector).toEqual({ v: 'su', x: 'ready' })
expect(setup.fixture).toBe('Journal, Notes, Archive')
})
it('refuses a cell that is not a LocaleFrame selector', () => {
expect(() => parseSelector('Frame{x}')).toThrow()
})
})
describe('Every state has a setup', () => {
const setups = visualStates['localization-site-languages'] ?? {}
it('covers the 41 ids and nothing else', () => {
const every = screen.groups.flatMap((group) => group.states.map((state) => state.id))
expect(Object.keys(setups).sort()).toEqual([...every].sort())
})
it('skips no state and grants no exception', () => {
for (const setup of Object.values(setups)) {
expect(setup.skip).toBeUndefined()
expect(setup.exception).toBeUndefined()
}
})
})
describe('Existing screens are unaffected', () => {
it('reports the parser as the only discovery for localization screens', () => {
const names = Object.keys(visualStates).filter((name) => name.startsWith('localization-'))
expect(names).toEqual(['localization-site-languages'])
})
})
+48 -4
View File
@@ -7,8 +7,9 @@ import { chromium, type Browser, type BrowserContext, type Page } from 'playwrig
import { icons as lucide } from '@iconify-json/lucide'
import { getIconData, getIcons, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils'
import { visualStates, type ApiMock, type DiffException, type VisualState } from './states'
import { localizationSizeLabel, parseLocalizationScreen } from './localizationScreen'
type Viewport = 'desktop' | 'mobile'
type Viewport = 'desktop' | 'tablet' | 'mobile'
type Theme = 'light' | 'dark'
interface Source {
@@ -24,6 +25,7 @@ interface Screen {
group: string
states: string[]
sources: Source[]
sizes?: Record<string, Viewport[]>
}
const VISUAL_REFERENCE =
@@ -596,6 +598,7 @@ const configFile = join(webRoot, 'vite.config.ts')
const frames: Record<Viewport, { width: number; height: number }> = {
desktop: { width: 1440, height: 900 },
tablet: { width: 1024, height: 768 },
mobile: { width: 390, height: 844 },
}
@@ -629,7 +632,7 @@ function budgetFor(cap: DiffException['maxPercent'] | undefined, viewport: Viewp
if (cap === undefined) {
return maxDiffPercent
}
return typeof cap === 'number' ? cap : cap[viewport]
return typeof cap === 'number' ? cap : cap[viewport === 'mobile' ? 'mobile' : 'desktop']
}
function describeCap(cap: DiffException['maxPercent']) {
@@ -660,7 +663,11 @@ function parseArgs(argv: string[]) {
'Usage: task visual:check -- <screen> [state] [--viewport=desktop|mobile] [--theme=light|dark]',
)
}
const viewports = (flags.get('viewport')?.split(',') ?? ['desktop', 'mobile']) as Viewport[]
const viewports = (flags.get('viewport')?.split(',') ?? [
'desktop',
'tablet',
'mobile',
]) as Viewport[]
const themes = (flags.get('theme')?.split(',') ?? ['light', 'dark']) as Theme[]
return { screen, state: stateWords.join(' ') || undefined, viewports, themes }
}
@@ -934,7 +941,39 @@ function rolesScreen(): Screen {
}
}
async function localizationScreen(name: string): Promise<Screen> {
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const parsed = parseLocalizationScreen(markdown)
const sources = parsed.groups.map(
(group): Source => ({
board: parsed.board,
group: group.label,
states: group.states.map((state) => state.id),
alias: Object.fromEntries(group.states.map((state) => [state.id, state.name])),
}),
)
const first = sources[0]!
return {
name,
board: first.board,
group: first.group,
states: sources.flatMap((source) => source.states),
sources,
sizes: Object.fromEntries(
parsed.groups.flatMap((group) =>
group.states.map((state) => [
state.id,
state.sizes.map(localizationSizeLabel) as Viewport[],
]),
),
),
}
}
async function readScreen(name: string): Promise<Screen> {
if (name.startsWith('localization-')) {
return localizationScreen(name)
}
if (name === 'image-variants') {
return variantsScreen()
}
@@ -1142,6 +1181,7 @@ const REALIGNED_BOARD_STATES = new Set(['9j. Navigation: scroll-spy on Content t
const SCROLL_REALIGN_MAX_DRIFT = 60
const BOARD_SCROLL_OFFSETS = {
desktop: [32],
tablet: [32],
mobile: [64],
}
@@ -1270,7 +1310,7 @@ async function captureDesign(
group: screen.group,
state,
...frames[viewport],
widths: [frames.desktop.width, frames.mobile.width],
widths: [frames.desktop.width, frames.tablet.width, frames.mobile.width],
},
)
if (!found) {
@@ -1467,6 +1507,10 @@ async function main() {
const sourceScreen = { ...screen, board: source.board, group: source.group }
const board = await openBoard(browser, design.origin, sourceScreen, theme)
for (const state of source.states) {
const wanted = screen.sizes?.[state] ?? ['desktop', 'mobile']
if (!wanted.includes(viewport)) {
continue
}
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
+75
View File
@@ -0,0 +1,75 @@
export interface LocalizationState {
id: string
name: string
selector: { v: string; x: string }
sizes: number[]
fixture: string
group: string
}
export interface LocalizationGroup {
label: string
states: LocalizationState[]
}
export interface LocalizationScreen {
board: string
groups: LocalizationGroup[]
}
const BOARD_LINE = /^Board:\s*([^.]+?)\./m
const GROUP_HEADING = /^## (L\d+[a-z]*) (.+)$/
const STATE_ROW = /^\| (loc\.[A-Za-z0-9.]+) \| /
const SELECTOR = /LocaleFrame\{s:\{v:"([^"]+)",x:"([^"]+)"\}\}/
export function parseSizes(cell: string) {
return cell
.split(',')
.map((part) => Number.parseInt(part.trim(), 10))
.filter((width) => Number.isFinite(width))
}
export function parseSelector(cell: string) {
const match = SELECTOR.exec(cell)
if (!match) {
throw new Error(`Not a LocaleFrame selector: ${cell}`)
}
return { v: match[1]!, x: match[2]! }
}
export function parseLocalizationScreen(markdown: string): LocalizationScreen {
const board = BOARD_LINE.exec(markdown)
if (!board) {
throw new Error('The localization screen file has no "Board:" reference line')
}
const groups: LocalizationGroup[] = []
for (const line of markdown.split('\n')) {
const heading = GROUP_HEADING.exec(line)
if (heading) {
groups.push({ label: `${heading[1]} ${heading[2]}`, states: [] })
continue
}
const group = groups[groups.length - 1]
if (!group || !STATE_ROW.test(line)) {
continue
}
const cells = line
.split('|')
.slice(1, -1)
.map((cell) => cell.trim())
const [id, name, , selector, sizes, fixture] = cells
group.states.push({
id: id!,
name: name!,
selector: parseSelector(selector!),
sizes: parseSizes(sizes!),
fixture: fixture!,
group: group.label,
})
}
return { board: `${board[1]}.dc.html`, groups: groups.filter((group) => group.states.length) }
}
export function localizationSizeLabel(width: number) {
return width === 1440 ? 'desktop' : width === 1024 ? 'tablet' : 'mobile'
}
+513
View File
@@ -0,0 +1,513 @@
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 })
+2
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
import type { Page } from 'playwright'
import { accessFor, customAccess, roleRefFor } from '../src/test/access'
import { approvedExceptions } from './approvedExceptions'
import { localizationStates } from './localizationStates'
import { rolesStates } from './rolesStates'
export interface ApiMock {
@@ -10539,4 +10540,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
withUsageException(withVariantsRowException({ ...mediaStates, ...mediaPurgeStates })),
),
'media-picker': pickerStates,
'localization-site-languages': localizationStates,
}