864 lines
26 KiB
TypeScript
864 lines
26 KiB
TypeScript
import type { Page } from 'playwright'
|
|
import { accessFor } from '../src/test/access'
|
|
import { siteLanguagesBody } from '../src/test/storyLists'
|
|
import type { ApiMock, DiffException, VisualState } from './states'
|
|
|
|
type Lang = 'en' | 'fr' | 'de' | 'ar'
|
|
|
|
interface Version {
|
|
id: number
|
|
lang: string
|
|
title: string
|
|
paragraphs: string[]
|
|
online: boolean
|
|
}
|
|
|
|
const FULL = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
|
const READ_ONLY = ['read']
|
|
const EDIT_NO_PUBLISH = ['read', 'edit', 'delete', 'create_translation']
|
|
const PUBLISH_NO_EDIT = ['read', 'publish']
|
|
const FULL_WITHOUT_CREATE = ['read', 'edit', 'publish', 'delete']
|
|
|
|
const FRENCH_TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
|
|
const CHANGED_TITLE = `${FRENCH_TITLE.slice(0, -1)}x`
|
|
const LONG_TITLE_LENGTH = 203
|
|
const LONG_TITLE =
|
|
'Comment nous chiffrons une rénovation de salle de bain, poste par poste, avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes, expliqués avant la commande du premier carreau et de la première vanne'
|
|
const MINUTE_MS = 60 * 1000
|
|
const SAVED_MINUTES_AGO = 3
|
|
const LOAD_SETTLE_MS = 400
|
|
|
|
export const paragraphs: Record<Lang, string[]> = {
|
|
en: [
|
|
'Most quotes fail because they hide the same three costs. Here is how we itemise them before the first tile is ordered.',
|
|
'Start with the room, not the catalogue. Measure the walls, note where the pipes run and photograph the existing fixtures.',
|
|
'Next, separate what is fixed from what is chosen. Plumbing and waterproofing are fixed. Tiles and taps are chosen.',
|
|
'Price the fixed items first and ask for a written range. A range with a stated assumption is more useful than a single figure.',
|
|
],
|
|
fr: [
|
|
"La plupart des devis échouent parce qu'ils cachent les trois mêmes coûts. Voici comment nous les détaillons avant de commander le premier carreau.",
|
|
'Commencez par la pièce, pas par le catalogue. Mesurez les murs, repérez le passage des canalisations et photographiez les équipements existants.',
|
|
"Séparez ensuite ce qui est fixe de ce qui est choisi. La plomberie et l'étanchéité sont fixes. Les carreaux et la robinetterie sont choisis.",
|
|
"Chiffrez d'abord les postes fixes et demandez une fourchette écrite.",
|
|
],
|
|
de: [
|
|
'Die meisten Angebote scheitern, weil sie dieselben drei Kosten verschweigen. So listen wir sie auf, bevor die erste Fliese bestellt wird.',
|
|
'Beginnen Sie mit dem Raum, nicht mit dem Katalog. Messen Sie die Wände und halten Sie fest, wo die Rohre verlaufen.',
|
|
],
|
|
ar: [
|
|
'تفشل معظم عروض الأسعار لأنها تُخفي التكاليف الثلاثة نفسها. إليك كيف نفصّلها قبل طلب أول بلاطة.',
|
|
'ابدأ بالغرفة لا بالكتالوج. قِس الجدران، وحدّد مسار الأنابيب، وصوّر التركيبات الحالية.',
|
|
'افصل بعد ذلك ما هو ثابت عمّا هو مختار. السباكة والعزل ثابتان، أما البلاط والصنابير فمختارة.',
|
|
],
|
|
}
|
|
|
|
const versions: Record<Lang, Version> = {
|
|
en: {
|
|
id: 10,
|
|
lang: 'en',
|
|
title: 'How we price a bathroom renovation',
|
|
paragraphs: paragraphs.en,
|
|
online: true,
|
|
},
|
|
fr: { id: 12, lang: 'fr', title: FRENCH_TITLE, paragraphs: paragraphs.fr, online: false },
|
|
de: {
|
|
id: 11,
|
|
lang: 'de',
|
|
title: 'So kalkulieren wir eine Badsanierung',
|
|
paragraphs: paragraphs.de,
|
|
online: true,
|
|
},
|
|
ar: {
|
|
id: 13,
|
|
lang: 'ar',
|
|
title: 'كيف نسعّر تجديد الحمّام',
|
|
paragraphs: paragraphs.ar,
|
|
online: false,
|
|
},
|
|
}
|
|
|
|
const updatedAt = new Date(Date.now() - SAVED_MINUTES_AGO * MINUTE_MS).toISOString()
|
|
const seoReady = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
|
const seoOneSuggestion = {
|
|
status: 'suggestions',
|
|
suggestions: [{ code: 'cover_none', field: 'cover_image_id', params: {} }],
|
|
slug: { available: true, suggestion: null },
|
|
}
|
|
export const ok = (body: unknown): ApiMock => ({ status: 200, body })
|
|
export const fails = (status = 500): ApiMock => ({ status, body: 'Internal server error' })
|
|
export const hangs: ApiMock = { status: 200, hang: true }
|
|
|
|
export const site = {
|
|
id: 1,
|
|
name: 'Journal',
|
|
key: 'journal',
|
|
site_name: 'Journal',
|
|
title_template: '%title% | %site%',
|
|
default_description: '',
|
|
base_url: 'https://journal.example.com',
|
|
default_social_image_id: null,
|
|
default_robots: 'index',
|
|
locale: 'en',
|
|
is_default: true,
|
|
article_count: 8,
|
|
public_url_pattern: null,
|
|
}
|
|
|
|
export interface Setup {
|
|
search?: string
|
|
current?: Lang
|
|
capabilities?: string[]
|
|
siblings?: Lang[]
|
|
siblingCapabilities?: Partial<Record<Lang, string[]>>
|
|
online?: Partial<Record<Lang, boolean>>
|
|
languageEnabled?: Partial<Record<Lang, boolean>>
|
|
languages?: unknown
|
|
versions?: Record<string, Version>
|
|
title?: string
|
|
paragraphs?: string[]
|
|
suggestion?: boolean
|
|
}
|
|
|
|
function entry(setup: Setup, lang: Lang) {
|
|
const version = versions[lang]
|
|
const current = lang === (setup.current ?? 'fr')
|
|
return {
|
|
id: version.id,
|
|
lang: version.lang,
|
|
title: version.title,
|
|
is_online: setup.online?.[lang] ?? version.online,
|
|
language_enabled: setup.languageEnabled?.[lang] ?? true,
|
|
capabilities: current
|
|
? (setup.capabilities ?? FULL)
|
|
: (setup.siblingCapabilities?.[lang] ?? FULL),
|
|
}
|
|
}
|
|
|
|
export function articleBody(setup: Setup, lang: Lang) {
|
|
const version = versions[lang]
|
|
const current = lang === (setup.current ?? 'fr')
|
|
const siblings = setup.siblings ?? ['en', 'fr', 'de']
|
|
const list = (siblings.includes(lang) ? siblings : [...siblings, lang]) as Lang[]
|
|
return {
|
|
id: version.id,
|
|
title: setup.title ?? version.title,
|
|
body: (setup.paragraphs ?? version.paragraphs).map((text) => `<p>${text}</p>`).join(''),
|
|
images: [1, 2, 3].map((id) => ({ id, url: `/images/${id}.png` })),
|
|
is_online: setup.online?.[lang] ?? version.online,
|
|
lang: version.lang,
|
|
story_id: 5,
|
|
revision: 3,
|
|
capabilities: current
|
|
? (setup.capabilities ?? FULL)
|
|
: (setup.siblingCapabilities?.[lang] ?? FULL),
|
|
translations: list.map((tag) => entry(setup, tag)),
|
|
site_id: 1,
|
|
authors: [{ id: 1, name: 'Inès Duarte', slug: 'ines-duarte' }],
|
|
created_at: '2026-03-12T09:14:00Z',
|
|
updated_at: updatedAt,
|
|
}
|
|
}
|
|
|
|
function session(): ApiMock {
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
id: 7,
|
|
is_verified: true,
|
|
username: 'marta',
|
|
email: 'marta@renew.example',
|
|
display_name: 'Marta Alves',
|
|
access: accessFor('owner'),
|
|
},
|
|
}
|
|
}
|
|
|
|
function articleApi(setup: Setup, lang: Lang): Record<string, ApiMock> {
|
|
const version = versions[lang]
|
|
return {
|
|
[`GET /v1/article/${version.id}`]: ok(articleBody(setup, lang)),
|
|
[`POST /v1/article/${version.id}/seo/check`]: ok(
|
|
setup.suggestion ? seoOneSuggestion : seoReady,
|
|
),
|
|
}
|
|
}
|
|
|
|
function editorApi(setup: Setup = {}, overrides: Record<string, ApiMock> = {}) {
|
|
const current = setup.current ?? 'fr'
|
|
const others = (['en', 'fr', 'de', 'ar'] as Lang[]).filter((lang) => lang !== current)
|
|
return {
|
|
'GET /v1/auth/me': session(),
|
|
'GET /v1/site/': ok([site]),
|
|
'GET /v1/site/journal/languages': ok(setup.languages ?? siteLanguagesBody('journal')),
|
|
'GET /v1/author/': ok({ items: [], total: 0 }),
|
|
...Object.assign({}, ...others.map((lang) => articleApi(setup, lang))),
|
|
...articleApi(setup, current),
|
|
...overrides,
|
|
} as Record<string, ApiMock>
|
|
}
|
|
|
|
function pathOf(setup: Setup) {
|
|
return `/article/${versions[setup.current ?? 'fr'].id}/edit${setup.search ?? ''}`
|
|
}
|
|
|
|
export function state(
|
|
setup: Setup,
|
|
steps?: (page: Page) => Promise<void>,
|
|
overrides: Record<string, ApiMock> = {},
|
|
exception?: DiffException,
|
|
): VisualState {
|
|
return {
|
|
path: pathOf(setup),
|
|
timezone: 'UTC',
|
|
api: editorApi(setup, overrides),
|
|
steps,
|
|
exception,
|
|
}
|
|
}
|
|
|
|
export const pending = (path: string): VisualState => ({ path, skip: 'pending' })
|
|
|
|
export function narrow(page: Page) {
|
|
return (page.viewportSize()?.width ?? 0) < 1024
|
|
}
|
|
|
|
export async function settle(page: Page) {
|
|
await page.waitForTimeout(LOAD_SETTLE_MS)
|
|
await page.mouse.move(0, 0)
|
|
}
|
|
|
|
async function scrollEverythingToTop(page: Page) {
|
|
await page.evaluate(() => {
|
|
document.querySelectorAll<HTMLElement>('*').forEach((element) => {
|
|
element.scrollTop = 0
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function editBody(page: Page) {
|
|
await page.locator('[role="textbox"][contenteditable="true"]').first().click()
|
|
await page.keyboard.press('Control+End')
|
|
await page.keyboard.type(' x')
|
|
await page.keyboard.press('Control+Home')
|
|
await scrollEverythingToTop(page)
|
|
await settle(page)
|
|
}
|
|
|
|
async function titleInput(page: Page) {
|
|
const input = page.getByRole('textbox', { name: /^Title/ }).first()
|
|
await input.waitFor()
|
|
return input
|
|
}
|
|
|
|
async function typeTitle(page: Page, value: string) {
|
|
const input = await titleInput(page)
|
|
await input.click()
|
|
await input.fill(value)
|
|
}
|
|
|
|
async function blurTitle(page: Page) {
|
|
await page.keyboard.press('Tab')
|
|
await settle(page)
|
|
}
|
|
|
|
async function openLanguageMenu(page: Page, name = 'French') {
|
|
await page.getByRole('button', { name: `Language: ${name}. Switch version` }).click()
|
|
await page.getByRole('menuitem').first().waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
async function chooseLanguage(page: Page, name: string) {
|
|
await openLanguageMenu(page, name === 'English' ? 'French' : 'English')
|
|
await page.getByRole('menuitem', { name: new RegExp(`^${name}`) }).click()
|
|
}
|
|
|
|
export async function press(page: Page, name: string | RegExp) {
|
|
const exact = typeof name === 'string'
|
|
await page.getByRole('button', { name, exact }).first().click()
|
|
await settle(page)
|
|
}
|
|
|
|
export async function openOverflow(page: Page) {
|
|
await page.getByRole('button', { name: 'More actions' }).first().click()
|
|
await page.getByRole('menuitem').first().waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
async function pressUnpublish(page: Page) {
|
|
if (narrow(page)) {
|
|
await openOverflow(page)
|
|
await page.getByRole('menuitem', { name: /^Unpublish/ }).click()
|
|
} else {
|
|
await page
|
|
.getByRole('button', { name: /^Unpublish/ })
|
|
.first()
|
|
.click()
|
|
}
|
|
await page.getByRole('alertdialog').or(page.getByRole('dialog')).first().waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
export async function confirmDialog(page: Page, label: string) {
|
|
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
|
|
await settle(page)
|
|
}
|
|
|
|
async function dirtyThenPress(page: Page, label: string | RegExp) {
|
|
await editBody(page)
|
|
await press(page, label)
|
|
}
|
|
|
|
async function chooseOverflowItem(page: Page, label: string) {
|
|
await openOverflow(page)
|
|
await page.getByRole('menuitem', { name: new RegExp(`^${label}`) }).click()
|
|
await page.getByRole('dialog').waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
const saveFails: Record<string, ApiMock> = { 'PATCH /v1/article/12': fails() }
|
|
const savedFrench = (online = false) =>
|
|
ok({ ...articleBody({ current: 'fr', online: { fr: online } }, 'fr') })
|
|
|
|
const ed = (
|
|
setup: Setup,
|
|
steps?: (page: Page) => Promise<void>,
|
|
overrides: Record<string, ApiMock> = {},
|
|
) => state({ suggestion: true, ...setup }, steps, overrides)
|
|
|
|
const l5: Record<string, VisualState> = {
|
|
'loc.l5.default': ed({}),
|
|
'loc.l5.dirty': ed({ capabilities: EDIT_NO_PUBLISH }, editBody),
|
|
'loc.l5.dirtyPublish': ed({}, editBody),
|
|
'loc.l5.cleanOnline': ed({ current: 'en' }),
|
|
'loc.l5.onlineDirty': ed({ current: 'en' }, editBody),
|
|
'loc.l5.saveFail': ed({}, (page) => dirtyThenPress(page, 'Save'), saveFails),
|
|
'loc.l5.pubFail': ed(
|
|
{},
|
|
async (page) => {
|
|
await editBody(page)
|
|
await press(page, 'Save and publish French')
|
|
await confirmDialog(page, 'Save and publish French')
|
|
},
|
|
{
|
|
'PATCH /v1/article/12': savedFrench(),
|
|
'PATCH /v1/article/12/publish': fails(),
|
|
},
|
|
),
|
|
'loc.l5.saving': ed({}, (page) => dirtyThenPress(page, 'Save'), {
|
|
'PATCH /v1/article/12': hangs,
|
|
}),
|
|
'loc.l5.saved': ed({ siblings: ['en', 'fr'] }, (page) => dirtyThenPress(page, 'Save'), {
|
|
'PATCH /v1/article/12': savedFrench(),
|
|
}),
|
|
'loc.l5.ovf': ed({}, async (page) => {
|
|
await editBody(page)
|
|
await openOverflow(page)
|
|
}),
|
|
'loc.l5.menu': ed({ siblings: ['en', 'fr', 'de', 'ar'] }, openLanguageMenu),
|
|
'loc.l5.two': ed(
|
|
{
|
|
siblings: ['en', 'fr'],
|
|
languages: languagesOf(['en', 'fr']),
|
|
},
|
|
openLanguageMenu,
|
|
),
|
|
'loc.l5.many': manyLanguages(),
|
|
'loc.l5.unsaved': ed({}, async (page) => {
|
|
await editBody(page)
|
|
await chooseLanguage(page, 'English')
|
|
await page.getByRole('dialog').waitFor()
|
|
await settle(page)
|
|
}),
|
|
'loc.l5.loading': ed(
|
|
{ siblings: ['en', 'fr', 'de'] },
|
|
async (page) => {
|
|
await chooseLanguage(page, 'English')
|
|
await page.waitForTimeout(LOAD_SETTLE_MS)
|
|
},
|
|
{ 'GET /v1/article/10': hangs },
|
|
),
|
|
'loc.l5.navFail': ed(
|
|
{ current: 'en', online: { en: false } },
|
|
async (page) => {
|
|
await openLanguageMenu(page, 'English')
|
|
await page.getByRole('menuitem', { name: /^French/ }).click()
|
|
await page.getByText("Couldn't open the French version", { exact: true }).waitFor()
|
|
await settle(page)
|
|
},
|
|
{ 'GET /v1/article/12': fails() },
|
|
),
|
|
'loc.l5.gone': ed({}, undefined, {
|
|
'GET /v1/article/12': { status: 404, body: 'Article not found' },
|
|
}),
|
|
'loc.l5.focus': ed({}, async (page) => {
|
|
await (await titleInput(page)).focus()
|
|
await settle(page)
|
|
}),
|
|
'loc.l5.titleEdit': ed({}, async (page) => {
|
|
const input = await titleInput(page)
|
|
await input.click()
|
|
await input.press('End')
|
|
await input.pressSequentially(' en 2026')
|
|
await settle(page)
|
|
}),
|
|
'loc.l5.titleUnsaved': ed({}, async (page) => {
|
|
const input = await titleInput(page)
|
|
await input.click()
|
|
await input.press('End')
|
|
await input.pressSequentially(' en 2026')
|
|
await blurTitle(page)
|
|
}),
|
|
'loc.l5.invalid': ed({}, async (page) => {
|
|
await typeTitle(page, '')
|
|
await settle(page)
|
|
}),
|
|
'loc.l5.titleShort': ed({}, async (page) => {
|
|
await typeTitle(page, 'Sa')
|
|
await settle(page)
|
|
}),
|
|
'loc.l5.titleLong': ed({}, async (page) => {
|
|
await typeTitle(page, LONG_TITLE.slice(0, LONG_TITLE_LENGTH))
|
|
await settle(page)
|
|
}),
|
|
'loc.l5.titleSaving': ed(
|
|
{},
|
|
async (page) => {
|
|
await typeTitle(page, CHANGED_TITLE)
|
|
await blurTitle(page)
|
|
await press(page, 'Save')
|
|
},
|
|
{ 'PATCH /v1/article/12': hangs },
|
|
),
|
|
'loc.l5.failed': ed(
|
|
{},
|
|
async (page) => {
|
|
await typeTitle(page, CHANGED_TITLE)
|
|
await blurTitle(page)
|
|
await press(page, 'Save')
|
|
},
|
|
saveFails,
|
|
),
|
|
}
|
|
|
|
function languagesOf(tags: string[]) {
|
|
const base = siteLanguagesBody('journal')
|
|
return {
|
|
...base,
|
|
languages: base.languages.filter((language) => tags.includes(language.lang)),
|
|
}
|
|
}
|
|
|
|
function manyLanguages(): VisualState {
|
|
const tags = ['en', 'en-GB', 'fr', 'de', 'it', 'ja', 'pt-BR', 'es', 'zh-Hans', 'ar', 'nl', 'pl']
|
|
const base = siteLanguagesBody('journal')
|
|
const template = base.languages[0]!
|
|
const many = {
|
|
...base,
|
|
languages: tags.map((lang) => ({ ...template, lang, is_default: lang === 'en' })),
|
|
}
|
|
const readable = ['en', 'en-GB', 'fr', 'de', 'it', 'ja']
|
|
const article = articleBody({ current: 'fr' }, 'fr')
|
|
const translations = readable.map((lang, index) => ({
|
|
...article.translations[0]!,
|
|
id: index === 2 ? 12 : 100 + index,
|
|
lang,
|
|
is_online: ['en', 'de', 'ja'].includes(lang),
|
|
}))
|
|
return {
|
|
path: '/article/12/edit',
|
|
timezone: 'UTC',
|
|
api: editorApi(
|
|
{ suggestion: true },
|
|
{
|
|
'GET /v1/site/journal/languages': ok(many),
|
|
'GET /v1/article/12': ok({ ...article, translations }),
|
|
},
|
|
),
|
|
steps: async (page) => {
|
|
await openLanguageMenu(page)
|
|
},
|
|
}
|
|
}
|
|
|
|
const l5b: Record<string, VisualState> = {
|
|
'loc.perm.readonly': state({ capabilities: READ_ONLY }),
|
|
'loc.l5b.roMenu': state({ capabilities: READ_ONLY }, openLanguageMenu),
|
|
'loc.perm.editNoPublish': state({ capabilities: EDIT_NO_PUBLISH }),
|
|
'loc.perm.publishNoEdit': state({ capabilities: PUBLISH_NO_EDIT }),
|
|
'loc.perm.siblingReadOnly': state(
|
|
{ siblings: ['en', 'fr', 'de', 'ar'], siblingCapabilities: { ar: READ_ONLY } },
|
|
openLanguageMenu,
|
|
),
|
|
'loc.perm.siblingHidden': state(
|
|
{ languages: languagesOf(['en', 'fr', 'de']) },
|
|
openLanguageMenu,
|
|
),
|
|
'loc.perm.createNotAll': state({ siblingCapabilities: { en: ['read', 'edit'] } }, openOverflow),
|
|
'loc.l5b.noAdd': state(
|
|
{
|
|
capabilities: FULL_WITHOUT_CREATE,
|
|
title: "Guide de l'enduit à la chaux",
|
|
},
|
|
openLanguageMenu,
|
|
),
|
|
'loc.l5b.withAdd': state(
|
|
{
|
|
siblings: ['en', 'fr', 'de'],
|
|
title: "Guide de l'enduit à la chaux",
|
|
},
|
|
openLanguageMenu,
|
|
),
|
|
}
|
|
|
|
const l8: Record<string, VisualState> = {
|
|
'loc.l8.publish': state({}, async (page) => {
|
|
await press(page, 'Publish French')
|
|
await page.getByRole('dialog').waitFor()
|
|
await settle(page)
|
|
}),
|
|
'loc.lang.publishDisabled': state({ languageEnabled: { fr: false } }),
|
|
'loc.l8.saveAndPublish': state({}, async (page) => {
|
|
await editBody(page)
|
|
await press(page, 'Save and publish French')
|
|
await page.getByRole('dialog').waitFor()
|
|
await settle(page)
|
|
}),
|
|
'loc.l8.saveFailPublish': state(
|
|
{},
|
|
async (page) => {
|
|
await editBody(page)
|
|
await press(page, 'Save and publish French')
|
|
await confirmDialog(page, 'Save and publish French')
|
|
await page.getByText("Couldn't save French", { exact: true }).first().waitFor()
|
|
await settle(page)
|
|
},
|
|
saveFails,
|
|
{
|
|
maxPercent: { desktop: 13, mobile: 21.5 },
|
|
reason: 'The board omits the banner / online alert that features/localization_editor_navigation.feature requires, shifting the page by 70-90px; the board is to be updated.',
|
|
approvedBy: 'Florian Sylvain',
|
|
},
|
|
),
|
|
'loc.l8.unpublish': state({ online: { fr: true } }, pressUnpublish),
|
|
'loc.l8.unpublishDirty': state({ online: { fr: true } }, async (page) => {
|
|
await editBody(page)
|
|
await pressUnpublish(page)
|
|
}),
|
|
'loc.l8.delete': state({}, (page) => chooseOverflowItem(page, 'Delete French version')),
|
|
'loc.l8.deleteSource': pending('/article/10/edit'),
|
|
'loc.l8.deleteLast': state(
|
|
{
|
|
current: 'en',
|
|
siblings: ['en'],
|
|
online: { en: true },
|
|
title: FRENCH_TITLE,
|
|
paragraphs: paragraphs.fr,
|
|
},
|
|
(page) => chooseOverflowItem(page, 'Delete article'),
|
|
),
|
|
'loc.l8.deleteStoryOk': state(
|
|
{ siblings: ['en', 'fr', 'de', 'ar'], online: { fr: true } },
|
|
(page) => chooseOverflowItem(page, 'Delete article'),
|
|
),
|
|
'loc.l8.deleteStory': state(
|
|
{ siblings: ['en', 'fr', 'de', 'ar'] },
|
|
async (page) => {
|
|
await chooseOverflowItem(page, 'Delete article')
|
|
await confirmDialog(page, 'Delete article')
|
|
await page.getByRole('dialog').getByText("You can't delete this article").waitFor()
|
|
await settle(page)
|
|
},
|
|
{ 'DELETE /v1/article/10': { status: 403, body: 'missing_permission' } },
|
|
),
|
|
'loc.l8.done': state(
|
|
{},
|
|
async (page) => {
|
|
await press(page, 'Publish French')
|
|
await confirmDialog(page, 'Publish French')
|
|
await page.getByText('French published', { exact: true }).waitFor()
|
|
await settle(page)
|
|
},
|
|
{ 'PATCH /v1/article/12/publish': savedFrench(true) },
|
|
{
|
|
maxPercent: { desktop: 11.1, mobile: 20.2 },
|
|
reason: 'The board omits the banner / online alert that features/localization_editor_navigation.feature requires, shifting the page by 70-90px; the board is to be updated.',
|
|
approvedBy: 'Florian Sylvain',
|
|
},
|
|
),
|
|
'loc.l8.fail': state(
|
|
{},
|
|
async (page) => {
|
|
await press(page, 'Publish French')
|
|
await confirmDialog(page, 'Publish French')
|
|
await page.getByText("Couldn't publish French", { exact: true }).waitFor()
|
|
await settle(page)
|
|
},
|
|
{ 'PATCH /v1/article/12/publish': fails() },
|
|
),
|
|
}
|
|
|
|
export const editorLocalizationStates: Record<string, VisualState> = { ...l5, ...l5b, ...l8 }
|
|
|
|
const RESTORED_AT = '2026-10-14T14:18:00Z'
|
|
const STORED_DRAFT_KEY = 'renewcms:article-draft:7:12'
|
|
const olderFrench = paragraphs.fr.slice(0, 2)
|
|
|
|
const THEIRS_AT = '2026-10-14T14:32:00Z'
|
|
const AGAIN_AT = '2026-10-14T14:41:00Z'
|
|
const FRENCH_SLUG = 'comment-nous-chiffrons-une-renovation'
|
|
|
|
const conflictHeaders = { 'X-Current-Revision': '4' }
|
|
const staleSave: ApiMock = { status: 409, body: 'revision_conflict', headers: conflictHeaders }
|
|
|
|
function frenchAtCalls(updatedAtByCall: Record<number, string>, extra: object = {}): ApiMock {
|
|
const loaded = articleBody({ suggestion: true }, 'fr')
|
|
return {
|
|
status: 200,
|
|
bodyFor: (_url, call) =>
|
|
call === 1
|
|
? loaded
|
|
: {
|
|
...loaded,
|
|
...extra,
|
|
revision: 4,
|
|
updated_at: updatedAtByCall[call] ?? loaded.updated_at,
|
|
},
|
|
}
|
|
}
|
|
|
|
const conflictApi = (times: Record<number, string> = { 2: THEIRS_AT }) => ({
|
|
'PATCH /v1/article/12': staleSave,
|
|
'GET /v1/article/12': frenchAtCalls(times),
|
|
})
|
|
|
|
async function openConflict(page: Page) {
|
|
await editBody(page)
|
|
await press(page, 'Save')
|
|
await page.getByRole('dialog').getByText('Someone else saved this version').first().waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
async function storeRestoredDraft(page: Page) {
|
|
await page.evaluate(
|
|
({ key, body, mirroredAt }) =>
|
|
sessionStorage.setItem(
|
|
key,
|
|
JSON.stringify({ body, revision: 3, mirrored_at: mirroredAt }),
|
|
),
|
|
{
|
|
key: STORED_DRAFT_KEY,
|
|
body: paragraphs.fr.map((text) => `<p>${text}</p>`).join(''),
|
|
mirroredAt: RESTORED_AT,
|
|
},
|
|
)
|
|
await page.reload()
|
|
await page.getByText('Restored unsaved changes from 14:18', { exact: true }).first().waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
async function pressInDialog(page: Page, name: string) {
|
|
await page.getByRole('dialog').getByRole('button', { name, exact: true }).click()
|
|
await settle(page)
|
|
}
|
|
|
|
function boardSite(id: number, key: string, name: string) {
|
|
return {
|
|
...site,
|
|
id,
|
|
key,
|
|
name,
|
|
site_name: name,
|
|
is_default: id === 1,
|
|
base_url: `https://${key}.example.com`,
|
|
}
|
|
}
|
|
|
|
const moveSites = [
|
|
boardSite(1, 'journal', 'Journal'),
|
|
boardSite(2, 'atelier', 'Atelier'),
|
|
boardSite(4, 'archive', 'Archive'),
|
|
boardSite(3, 'notes', 'Notes'),
|
|
]
|
|
|
|
const moveSetup: Setup = {
|
|
siblings: ['en', 'fr', 'de', 'ar'],
|
|
siblingCapabilities: { ar: READ_ONLY },
|
|
online: { en: true, fr: false, de: true, ar: false },
|
|
}
|
|
|
|
const movedAll = {
|
|
story_id: 5,
|
|
site: 'atelier',
|
|
site_id: 2,
|
|
article_ids: [10, 12, 11, 13],
|
|
}
|
|
|
|
const moveApi = (overrides: Record<string, ApiMock> = {}) => ({
|
|
'GET /v1/site/': ok(moveSites),
|
|
'GET /v1/site/notes/languages': ok(languagesOf(['en'])),
|
|
'GET /v1/site/atelier/languages': ok(languagesOf(['en', 'fr', 'de'])),
|
|
'POST /v1/story/5/move': ok(movedAll),
|
|
...overrides,
|
|
})
|
|
|
|
async function openMovePanel(page: Page) {
|
|
if (narrow(page)) {
|
|
await page.getByRole('button', { name: 'Details', exact: true }).click()
|
|
await page.getByRole('button', { name: /^Site and authors/ }).click()
|
|
await settle(page)
|
|
}
|
|
await page.getByRole('button', { name: 'Site', exact: true }).first().click()
|
|
await page.getByRole('listbox', { name: 'Site' }).waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
async function chooseMoveTarget(page: Page, name: string) {
|
|
await openMovePanel(page)
|
|
await page.getByRole('option', { name }).click()
|
|
await page.getByRole('button', { name: 'Move article…', exact: true }).click()
|
|
await page.getByRole('dialog').getByText(`Move to ${name}?`).waitFor()
|
|
await settle(page)
|
|
}
|
|
|
|
async function confirmMove(page: Page, name: string) {
|
|
await chooseMoveTarget(page, name)
|
|
await pressInDialog(page, 'Move article')
|
|
}
|
|
|
|
const mv = (
|
|
steps: (page: Page) => Promise<void>,
|
|
overrides: Record<string, ApiMock> = {},
|
|
setup: Setup = moveSetup,
|
|
exception?: DiffException,
|
|
) => state({ suggestion: true, ...setup }, steps, moveApi(overrides), exception)
|
|
|
|
export const conflictsMovesStates: Record<string, VisualState> = {
|
|
'loc.l11.session': pending('/article/12/edit'),
|
|
'loc.l11.restored': state({ paragraphs: olderFrench }, async (page) => {
|
|
await page.evaluate(
|
|
({ key, body, mirroredAt }) =>
|
|
sessionStorage.setItem(key, JSON.stringify({ body, mirrored_at: mirroredAt })),
|
|
{
|
|
key: STORED_DRAFT_KEY,
|
|
body: paragraphs.fr.map((text) => `<p>${text}</p>`).join(''),
|
|
mirroredAt: RESTORED_AT,
|
|
},
|
|
)
|
|
await page.reload()
|
|
await page.getByText('Restored unsaved changes from 14:18', { exact: true }).waitFor()
|
|
await settle(page)
|
|
}),
|
|
'loc.conflict': state({ suggestion: true }, openConflict, conflictApi()),
|
|
'loc.l11.conflictRestored': state(
|
|
{ suggestion: true, paragraphs: olderFrench },
|
|
async (page) => {
|
|
await storeRestoredDraft(page)
|
|
await press(page, 'Save')
|
|
await page
|
|
.getByRole('dialog')
|
|
.getByText('Someone else saved this version')
|
|
.first()
|
|
.waitFor()
|
|
await settle(page)
|
|
},
|
|
conflictApi(),
|
|
),
|
|
'loc.l11.reloadConfirm': state(
|
|
{ suggestion: true },
|
|
async (page) => {
|
|
await openConflict(page)
|
|
await pressInDialog(page, 'Reload theirs')
|
|
},
|
|
conflictApi(),
|
|
),
|
|
'loc.l11.conflict2': state(
|
|
{ suggestion: true },
|
|
async (page) => {
|
|
await openConflict(page)
|
|
await pressInDialog(page, 'Save over theirs')
|
|
await page.getByText("Couldn't replace theirs").first().waitFor()
|
|
await settle(page)
|
|
},
|
|
conflictApi({ 2: THEIRS_AT, 3: AGAIN_AT }),
|
|
),
|
|
'loc.l11.download': state(
|
|
{ suggestion: true },
|
|
async (page) => {
|
|
await openConflict(page)
|
|
await pressInDialog(page, 'Download my version')
|
|
await page.getByText('Download started', { exact: true }).first().waitFor()
|
|
await settle(page)
|
|
},
|
|
conflictApi(),
|
|
),
|
|
'loc.l11b.moveSelect': mv(openMovePanel),
|
|
'loc.l11b.moveSelectHidden': pending('/article/12/edit'),
|
|
'loc.l11b.moveConfirm': mv((page) => chooseMoveTarget(page, 'Atelier')),
|
|
'loc.l11b.moveBusy': mv((page) => confirmMove(page, 'Atelier'), {
|
|
'POST /v1/story/5/move': hangs,
|
|
}),
|
|
'loc.l11b.moveDone': mv(
|
|
async (page) => {
|
|
await confirmMove(page, 'Atelier')
|
|
await page.getByText('Moved to Atelier', { exact: true }).first().waitFor()
|
|
await settle(page)
|
|
},
|
|
{ 'GET /v1/article/12': frenchAtCalls({}, { site_id: 2, revision: 4 }) },
|
|
),
|
|
'loc.l11b.move': mv(
|
|
async (page) => {
|
|
await confirmMove(page, 'Notes')
|
|
await page
|
|
.getByRole('dialog')
|
|
.getByText("Notes doesn't enable every language")
|
|
.waitFor()
|
|
await settle(page)
|
|
},
|
|
{
|
|
'POST /v1/story/5/move': {
|
|
status: 422,
|
|
body: 'language_not_enabled',
|
|
headers: { 'X-Missing-Languages': 'fr,de,ar' },
|
|
},
|
|
},
|
|
),
|
|
'loc.l11b.moveSlug': mv(
|
|
async (page) => {
|
|
await confirmMove(page, 'Atelier')
|
|
await page
|
|
.getByRole('dialog')
|
|
.getByText('An address is already used on Atelier')
|
|
.waitFor()
|
|
await settle(page)
|
|
},
|
|
{
|
|
'POST /v1/story/5/move': {
|
|
status: 409,
|
|
body: 'slug_conflict',
|
|
headers: { 'X-Conflicting-Articles': '12' },
|
|
},
|
|
'GET /v1/article/12': {
|
|
status: 200,
|
|
bodyFor: (_url, call) => ({
|
|
...articleBody({ suggestion: true }, 'fr'),
|
|
...(call > 1 ? { slug: FRENCH_SLUG } : {}),
|
|
}),
|
|
},
|
|
},
|
|
moveSetup,
|
|
{
|
|
maxPercent: { desktop: 10, mobile: 17.4 },
|
|
reason: 'The board alert has an Edit slug button; Edit slug stays in slice 6, so the board and slice 6 are to be reconciled.',
|
|
approvedBy: 'Florian Sylvain',
|
|
},
|
|
),
|
|
'loc.l11b.movePerm': mv(
|
|
async (page) => {
|
|
await confirmMove(page, 'Atelier')
|
|
await page.getByRole('dialog').getByText("You can't move this article").waitFor()
|
|
await settle(page)
|
|
},
|
|
{ 'POST /v1/story/5/move': { status: 403, body: 'missing_permission' } },
|
|
),
|
|
}
|