feat: site languages, setup and site URL settings
This commit is contained in:
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
@@ -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++
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,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,
|
||||
}
|
||||
Reference in new issue
Block a user