refac: missing mutation tests, spa specs, checks shown to pass, touch targets, catalogue entry, production code
This commit is contained in:
1 parent
7c15206170
commit
3e02e14da4
48 files changed
+2173
-83
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import EditSlugDialog from '../EditSlugDialog.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
const SLUG = 'comment-nous-chiffrons-une-renovation'
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return { language: 'fr', slug: SLUG, published: false, pending: false, error: '', ...extra }
|
||||
}
|
||||
|
||||
function input() {
|
||||
return document.body.querySelector<HTMLInputElement>('#edit-slug-input')!
|
||||
}
|
||||
|
||||
function confirmButton() {
|
||||
return byText('button', 'Change address') as HTMLButtonElement
|
||||
}
|
||||
|
||||
async function typeSlug(value: string) {
|
||||
input().value = value
|
||||
input().dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('Edit slug (loc.slug)', () => {
|
||||
it('shows the title, the scope, the current slug and the title note', async () => {
|
||||
await mountInApp(EditSlugDialog, props())
|
||||
|
||||
expect(text(document.body)).toContain('Edit slug for the fr version')
|
||||
expect(text(document.body)).toContain('The address changes for fr only.')
|
||||
expect(text(document.body)).toContain('Editing the title never changes the slug.')
|
||||
expect(input().value).toBe(SLUG)
|
||||
})
|
||||
|
||||
it.todo(
|
||||
'focuses the slug field on open: the modal auto-focus moves focus to the close button after the dialog focused the field',
|
||||
)
|
||||
|
||||
it('keeps Change address disabled while the slug is unchanged or empty', async () => {
|
||||
await mountInApp(EditSlugDialog, props())
|
||||
|
||||
expect(confirmButton().disabled).toBe(true)
|
||||
await typeSlug(' ')
|
||||
expect(confirmButton().disabled).toBe(true)
|
||||
await typeSlug('couts-de-renovation')
|
||||
expect(confirmButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('emits the normalized slug on Change address', async () => {
|
||||
const wrapper = await mountInApp(EditSlugDialog, props())
|
||||
|
||||
await typeSlug('Couts de Renovation')
|
||||
confirmButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(EditSlugDialog).emitted('confirm')).toEqual([
|
||||
['couts-de-renovation'],
|
||||
])
|
||||
})
|
||||
|
||||
it('warns about old links on a published version only', async () => {
|
||||
await mountInApp(EditSlugDialog, props({ published: true }))
|
||||
|
||||
expect(text(document.body)).toContain('This version is published')
|
||||
expect(text(document.body)).toContain(
|
||||
'Links to the old address will stop working. RenewCMS does not create redirects.',
|
||||
)
|
||||
unmountApp()
|
||||
|
||||
await mountInApp(EditSlugDialog, props())
|
||||
expect(text(document.body)).not.toContain('This version is published')
|
||||
})
|
||||
|
||||
it('shows the error as an alert and marks the field invalid', async () => {
|
||||
await mountInApp(
|
||||
EditSlugDialog,
|
||||
props({ error: `The slug "${SLUG}" is already used on Journal.` }),
|
||||
)
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')
|
||||
expect(text(alert)).toBe(`The slug "${SLUG}" is already used on Journal.`)
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('emits close from Cancel', async () => {
|
||||
const wrapper = await mountInApp(EditSlugDialog, props())
|
||||
|
||||
byText('button', 'Cancel')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(EditSlugDialog).emitted('close')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { PUBLIC_LOOKUP_ROUTE, PUBLIC_STORY_ROUTE } from '@/lib/publicArticleRoutes'
|
||||
import IntegrationLanguages from '../IntegrationLanguages.vue'
|
||||
import { mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
function card(id: string) {
|
||||
return document.body.querySelector(`[data-testid="${id}"]`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unmountApp()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Integration: Languages section', () => {
|
||||
it('anchors the section under the Languages heading with its intro', async () => {
|
||||
await mountInApp(IntegrationLanguages)
|
||||
|
||||
const section = card('integration-languages')!
|
||||
expect(section.getAttribute('data-anchor')).toBe('languages')
|
||||
expect(section.getAttribute('aria-labelledby')).toBe('h-languages')
|
||||
expect(text(section.querySelector('#h-languages'))).toBe('Languages')
|
||||
expect(text(section)).toContain(
|
||||
'Read one published language of an article, or resolve an article in a language. Both requests are public.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the exact lookup route, its no-fallback rule and a sample with alternates', async () => {
|
||||
await mountInApp(IntegrationLanguages)
|
||||
|
||||
const lookup = text(card('integration-lookup'))
|
||||
expect(lookup).toContain('1. Exact lookup: site + language + slug')
|
||||
expect(lookup).toContain(PUBLIC_LOOKUP_ROUTE)
|
||||
expect(lookup).toContain('It never falls back, because a slug belongs to one language.')
|
||||
expect(lookup).toContain('"alternates": [{ "id": 10, "lang": "en"')
|
||||
})
|
||||
|
||||
it('shows the story route with the default fallback rules', async () => {
|
||||
await mountInApp(IntegrationLanguages)
|
||||
|
||||
const story = text(card('integration-story'))
|
||||
expect(story).toContain('2. Resolve by article group, with fallback')
|
||||
expect(story).toContain(PUBLIC_STORY_ROUTE)
|
||||
expect(story).toContain('"requested_lang": "fr", "fallback": true')
|
||||
expect(story).toContain('drafts are never returned.')
|
||||
})
|
||||
|
||||
it('lists the four error codes and what each means', async () => {
|
||||
await mountInApp(IntegrationLanguages)
|
||||
|
||||
const errors = text(card('integration-errors'))
|
||||
expect(errors).toContain('3. Errors')
|
||||
expect(errors).toContain(
|
||||
'article_not_found · language_not_available · site_not_found · invalid_language',
|
||||
)
|
||||
expect(errors).toContain('site_not_found: no site has that key.')
|
||||
})
|
||||
|
||||
it('keeps the existing article-ID routes listed as unchanged', async () => {
|
||||
await mountInApp(IntegrationLanguages)
|
||||
|
||||
const unchanged = text(card('integration-unchanged'))
|
||||
expect(unchanged).toContain('Existing, unchanged')
|
||||
expect(unchanged).toContain('GET /v1/article/{id}')
|
||||
expect(unchanged).toContain('PATCH /v1/article/{id}/publish')
|
||||
expect(unchanged).toContain('Editor: /article/{id}/edit')
|
||||
expect(unchanged).toContain(
|
||||
'Each article ID is one language version. Article-ID links keep working.',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates the copy and keeps the routes and codes literal in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountInApp(IntegrationLanguages)
|
||||
|
||||
const section = text(card('integration-languages'))
|
||||
expect(text(card('integration-languages')!.querySelector('#h-languages'))).toBe('Langues')
|
||||
expect(section).toContain(PUBLIC_LOOKUP_ROUTE)
|
||||
expect(section).toContain('article_not_found · language_not_available')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import type { LocalizedSeoContext } from '@/lib/localizedSeo'
|
||||
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||
import router from '@/router'
|
||||
import SeoLanguageCards from '../SeoLanguageCards.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
|
||||
const CANONICAL = 'https://journal.example.com/fr/comment-nous-chiffrons-une-renovation'
|
||||
|
||||
const ALTERNATES = [
|
||||
{
|
||||
id: 10,
|
||||
lang: 'en',
|
||||
story_id: 5,
|
||||
slug: 'how-we-price-a-bathroom-renovation',
|
||||
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
|
||||
},
|
||||
{ id: 11, lang: 'de', story_id: 5, slug: 'so-kalkulieren-wir-eine-badsanierung', url: null },
|
||||
]
|
||||
|
||||
function resolved(locale = 'fr_FR'): ResolvedSeo {
|
||||
return {
|
||||
title: 'Couts de renovation | Journal',
|
||||
description: '',
|
||||
canonical_url: CANONICAL,
|
||||
robots: 'index, follow',
|
||||
og: {
|
||||
type: 'article',
|
||||
title: '',
|
||||
description: '',
|
||||
image: '',
|
||||
image_alt: '',
|
||||
url: '',
|
||||
site_name: 'Journal',
|
||||
locale,
|
||||
},
|
||||
twitter: { card: 'summary', title: '', description: '', image: '', image_alt: '' },
|
||||
image: { url: '', alt: '', source: 'none' },
|
||||
authors: [],
|
||||
published_at: null,
|
||||
modified_at: '2026-09-26T11:02:00Z',
|
||||
fallbacks: { title: 'title', description: 'none', image: 'none', canonical: 'pattern' },
|
||||
json_ld: {},
|
||||
alternates: ALTERNATES,
|
||||
} as ResolvedSeo
|
||||
}
|
||||
|
||||
function context(overrides: Partial<LocalizedSeoContext> = {}): LocalizedSeoContext {
|
||||
return {
|
||||
lang: 'fr',
|
||||
siteName: 'Journal',
|
||||
siteKey: 'journal',
|
||||
pattern: PATTERN,
|
||||
defaultLang: 'en',
|
||||
disabledLangs: ['es'],
|
||||
published: true,
|
||||
socialState: 'derived',
|
||||
resolved: resolved(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function card(id: string) {
|
||||
return document.body.querySelector(`[data-testid="${id}"]`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unmountApp()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Search and sharing cards for one language', () => {
|
||||
it('shows the canonical with its source note', async () => {
|
||||
await mountInApp(SeoLanguageCards, { context: context() })
|
||||
|
||||
const canonical = text(card('seo-canonical-card'))
|
||||
expect(canonical).toContain('Canonical')
|
||||
expect(canonical).toContain(CANONICAL)
|
||||
expect(canonical).toContain(`Built from the Public URL pattern ${PATTERN}.`)
|
||||
})
|
||||
|
||||
it('lists the published alternates with their URL, or their slug without one, and the disabled note', async () => {
|
||||
await mountInApp(SeoLanguageCards, { context: context() })
|
||||
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="seo-alternate-row"]')]
|
||||
expect(rows.map((row) => text(row))).toEqual([
|
||||
'en https://journal.example.com/en/how-we-price-a-bathroom-renovation Published',
|
||||
'de so-kalkulieren-wir-eine-badsanierung Published',
|
||||
])
|
||||
expect(rows[0]!.querySelector('[aria-label]')!.getAttribute('aria-label')).toBe('English')
|
||||
const alternates = text(card('seo-alternates-card'))
|
||||
expect(alternates).toContain('Alternate languages')
|
||||
expect(alternates).toContain('Published versions in enabled languages only.')
|
||||
expect(alternates).toContain('Drafts and the disabled Spanish language are left out.')
|
||||
})
|
||||
|
||||
it('shows the derived social locale', async () => {
|
||||
await mountInApp(SeoLanguageCards, { context: context() })
|
||||
|
||||
const social = text(card('seo-social-card'))
|
||||
expect(social).toContain('Social locale')
|
||||
expect(social).toContain('og:locale')
|
||||
expect(social).toContain('Derived: fr_FR')
|
||||
})
|
||||
|
||||
it('shows Social locale not set when nothing resolves', async () => {
|
||||
await mountInApp(SeoLanguageCards, { context: context({ resolved: resolved('') }) })
|
||||
|
||||
expect(text(card('seo-social-card'))).toContain('Social locale not set')
|
||||
})
|
||||
|
||||
it('replaces the canonical and alternates with Public URL not configured and opens its settings', async () => {
|
||||
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
await mountInApp(SeoLanguageCards, { context: context({ pattern: null }) })
|
||||
|
||||
expect(card('seo-canonical-card')).toBeNull()
|
||||
expect(card('seo-alternates-card')).toBeNull()
|
||||
expect(text(document.body)).toContain('Public URL not configured')
|
||||
expect(text(document.body)).toContain(
|
||||
'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.',
|
||||
)
|
||||
expect(card('seo-social-card')).not.toBeNull()
|
||||
|
||||
byText('button', 'Open Public URL settings')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/settings/sites/journal/public-url')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import type { useEditorVersions } from '@/composables/useEditorVersions'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { useEditorLocalizedSeo } from '@/composables/useEditorLocalizedSeo'
|
||||
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||
import type { SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
resolved: { value: null as unknown },
|
||||
load: vi.fn(),
|
||||
toasts: [] as (() => { title: string; description: string; color: string })[],
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useResolvedSeo', async () => {
|
||||
const { ref: vueRef } = await import('vue')
|
||||
const resolved = vueRef<unknown>(null)
|
||||
fixtures.resolved = resolved
|
||||
return { useResolvedSeo: () => ({ resolved, load: fixtures.load }) }
|
||||
})
|
||||
|
||||
vi.mock('@/i18n/presentation', () => ({
|
||||
useLocalizedToast: () => ({
|
||||
add: (content: () => { title: string; description: string; color: string }) => {
|
||||
fixtures.toasts.push(content)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const SITE = {
|
||||
id: 1,
|
||||
key: 'journal',
|
||||
name: 'Journal (internal)',
|
||||
site_name: 'Journal',
|
||||
public_url_pattern: 'https://journal.example.com/{lang}/{slug}',
|
||||
} as Site
|
||||
|
||||
function language(lang: string, extra: Partial<SiteLanguage> = {}): SiteLanguage {
|
||||
return {
|
||||
lang,
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
version_count: 1,
|
||||
published_count: 1,
|
||||
social_locale: null,
|
||||
social_locale_state: 'derived',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const LANGUAGES = [
|
||||
language('en', { is_default: true }),
|
||||
language('fr', { social_locale_state: 'set', social_locale: 'fr_CA' }),
|
||||
language('es', { enabled: false, published_count: 2 }),
|
||||
language('it', { enabled: false, published_count: 0 }),
|
||||
]
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
multilingual?: boolean
|
||||
site?: Site | undefined
|
||||
article?: Partial<Article> | null
|
||||
outcome?: SaveOutcome
|
||||
panelOpen?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const article = ref<Article | null>(
|
||||
options.article === null
|
||||
? null
|
||||
: ({ id: 12, lang: 'fr', is_online: true, ...options.article } as Article),
|
||||
)
|
||||
const site = ref<Site | undefined>('site' in options ? options.site : SITE)
|
||||
const panelOpen = ref(options.panelOpen ?? false)
|
||||
const seo = ref({ slug: 'couts', seo_title: '' })
|
||||
const draft = {
|
||||
seo,
|
||||
editSeo: vi.fn((next: Record<string, unknown>) => {
|
||||
seo.value = { ...seo.value, ...next }
|
||||
}),
|
||||
} as unknown as ReturnType<typeof useArticleDraft>
|
||||
const versions = {
|
||||
multilingual: computed(() => options.multilingual ?? true),
|
||||
siteLanguages: ref(LANGUAGES),
|
||||
} as unknown as ReturnType<typeof useEditorVersions>
|
||||
const save = vi.fn(async () => options.outcome ?? 'saved')
|
||||
const seoState = useEditorLocalizedSeo({
|
||||
articleId: 12,
|
||||
article,
|
||||
site: computed(() => site.value),
|
||||
versions,
|
||||
draft,
|
||||
panelOpen,
|
||||
save,
|
||||
})
|
||||
return { seoState, article, panelOpen, draft, save, seo }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
fixtures.load.mockReset()
|
||||
fixtures.toasts.length = 0
|
||||
;(fixtures.resolved as { value: unknown }).value = null
|
||||
})
|
||||
|
||||
describe('Localized SEO context', () => {
|
||||
it('builds the context from the site, the version and the site languages', () => {
|
||||
const { seoState } = setup()
|
||||
|
||||
expect(seoState.context.value).toEqual({
|
||||
lang: 'fr',
|
||||
siteName: 'Journal',
|
||||
siteKey: 'journal',
|
||||
pattern: 'https://journal.example.com/{lang}/{slug}',
|
||||
defaultLang: 'en',
|
||||
disabledLangs: ['es'],
|
||||
published: true,
|
||||
socialState: 'set',
|
||||
resolved: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the site name, a null pattern and no default', () => {
|
||||
const { seoState } = setup({
|
||||
site: { id: 2, key: 'atelier', name: 'Atelier', site_name: '' } as Site,
|
||||
article: { lang: 'de', is_online: false },
|
||||
})
|
||||
|
||||
expect(seoState.context.value).toMatchObject({
|
||||
siteName: 'Atelier',
|
||||
pattern: null,
|
||||
published: false,
|
||||
socialState: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the resolved SEO once it is loaded', async () => {
|
||||
const { seoState } = setup()
|
||||
const answer = { canonical_url: 'https://journal.example.com/fr/couts' } as ResolvedSeo
|
||||
|
||||
;(fixtures.resolved as { value: unknown }).value = answer
|
||||
await nextTick()
|
||||
|
||||
expect(seoState.context.value?.resolved).toStrictEqual(answer)
|
||||
})
|
||||
|
||||
it('has no context on a single-language site, without a site or without a language', () => {
|
||||
expect(setup({ multilingual: false }).seoState.context.value).toBeNull()
|
||||
expect(setup({ site: undefined }).seoState.context.value).toBeNull()
|
||||
expect(setup({ article: { lang: '' } }).seoState.context.value).toBeNull()
|
||||
expect(setup({ article: null }).seoState.context.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resolved SEO loading', () => {
|
||||
it('loads when the panel opens with a context, and not before', async () => {
|
||||
const { panelOpen } = setup()
|
||||
expect(fixtures.load).not.toHaveBeenCalled()
|
||||
|
||||
panelOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(fixtures.load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('loads at once when the panel is already open', () => {
|
||||
setup({ panelOpen: true })
|
||||
|
||||
expect(fixtures.load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never loads without a context', async () => {
|
||||
const { panelOpen } = setup({ multilingual: false })
|
||||
|
||||
panelOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(fixtures.load).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit slug', () => {
|
||||
it('opens with no error and closes on request', () => {
|
||||
const { seoState } = setup()
|
||||
|
||||
seoState.openSlugDialog()
|
||||
expect(seoState.slugDialogOpen.value).toBe(true)
|
||||
expect(seoState.slugError.value).toBe('')
|
||||
seoState.closeSlugDialog()
|
||||
expect(seoState.slugDialogOpen.value).toBe(false)
|
||||
})
|
||||
|
||||
it('saves the slug, closes, reloads the resolved SEO and confirms with a toast', async () => {
|
||||
const { seoState, save, seo } = setup()
|
||||
seoState.openSlugDialog()
|
||||
|
||||
const pending = seoState.confirmSlug('couts-de-renovation')
|
||||
expect(seoState.slugPending.value).toBe(true)
|
||||
await pending
|
||||
await flushPromises()
|
||||
|
||||
expect(seo.value.slug).toBe('couts-de-renovation')
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
expect(seoState.slugPending.value).toBe(false)
|
||||
expect(seoState.slugDialogOpen.value).toBe(false)
|
||||
expect(fixtures.load).toHaveBeenCalledTimes(1)
|
||||
const toast = fixtures.toasts[0]!()
|
||||
expect(toast.color).toBe('success')
|
||||
expect(toast.title).toBe('Address changed')
|
||||
expect(toast.description).toBe('The address of the fr version is now couts-de-renovation.')
|
||||
})
|
||||
|
||||
it('stays open with the taken slug named on the site', async () => {
|
||||
const { seoState } = setup({ outcome: 'slug-taken' })
|
||||
seoState.openSlugDialog()
|
||||
|
||||
await seoState.confirmSlug('kosten')
|
||||
|
||||
expect(seoState.slugDialogOpen.value).toBe(true)
|
||||
expect(seoState.slugError.value).toBe('The slug "kosten" is already used on Journal.')
|
||||
expect(fixtures.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stays open with the slug rules on an invalid slug', async () => {
|
||||
const { seoState } = setup({ outcome: 'invalid-slug' })
|
||||
seoState.openSlugDialog()
|
||||
|
||||
await seoState.confirmSlug('--')
|
||||
|
||||
expect(seoState.slugDialogOpen.value).toBe(true)
|
||||
expect(seoState.slugError.value).toBe(
|
||||
'Lowercase letters, numbers and hyphens. Unique on Journal.',
|
||||
)
|
||||
})
|
||||
|
||||
it('closes on a revision conflict so the conflict dialog can take over', async () => {
|
||||
const { seoState } = setup({ outcome: 'conflict' })
|
||||
seoState.openSlugDialog()
|
||||
|
||||
await seoState.confirmSlug('couts-2')
|
||||
|
||||
expect(seoState.slugDialogOpen.value).toBe(false)
|
||||
expect(seoState.slugError.value).toBe('')
|
||||
expect(fixtures.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stays open without a message on another failure, and reopening clears the last error', async () => {
|
||||
const { seoState } = setup({ outcome: 'error' })
|
||||
seoState.openSlugDialog()
|
||||
|
||||
await seoState.confirmSlug('couts-3')
|
||||
|
||||
expect(seoState.slugDialogOpen.value).toBe(true)
|
||||
expect(seoState.slugError.value).toBe('')
|
||||
seoState.openSlugDialog()
|
||||
expect(seoState.slugError.value).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'PATCH /v1/article/{id}/publish',
|
||||
'PATCH /v1/article/{id}/unpublish',
|
||||
'DELETE /v1/article/{id}',
|
||||
'DELETE /v1/story/{story_id}',
|
||||
'POST /v1/article/{id}/seo/check',
|
||||
],
|
||||
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
|
||||
@@ -245,7 +246,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 45 key-callable routes in 9 groups, in order', () => {
|
||||
it('holds exactly the 46 key-callable routes in 9 groups, in order', () => {
|
||||
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
@@ -262,12 +263,12 @@ describe('Catalogue routes', () => {
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
),
|
||||
).toEqual(EXPECTED_GROUPS)
|
||||
expect(allEndpoints()).toHaveLength(45)
|
||||
expect(allEndpoints()).toHaveLength(46)
|
||||
})
|
||||
|
||||
it('counts the routes of each group', () => {
|
||||
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
|
||||
10, 2, 5, 5, 2, 11, 3, 6, 1,
|
||||
11, 2, 5, 5, 2, 11, 3, 6, 1,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -297,7 +298,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(9 + 45 + 40 + 6)
|
||||
expect(described).toHaveLength(9 + 46 + 40 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
@@ -632,9 +633,10 @@ describe('Response samples use the real domain JSON keys', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('answers 204 for the three deletes without a JSON body', () => {
|
||||
it('answers 204 for the four deletes without a JSON body', () => {
|
||||
for (const route of [
|
||||
'DELETE /v1/article/{id}',
|
||||
'DELETE /v1/story/{story_id}',
|
||||
'DELETE /v1/media/{id}',
|
||||
'DELETE /v1/user/invite/{id}',
|
||||
]) {
|
||||
|
||||
@@ -1076,6 +1076,23 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
NO_CONTENT_RESPONSE,
|
||||
{ query: [FORMAT_QUERY] },
|
||||
),
|
||||
endpoint(
|
||||
'story-del',
|
||||
'DELETE',
|
||||
'/v1/story/{story_id}',
|
||||
'Delete a whole article: every version of the story, readable or not, in one step. Needs the permission to delete content at scope all.',
|
||||
'Verified user',
|
||||
verified(
|
||||
articleErrors(
|
||||
e(400, ID_FORMAT),
|
||||
eMissingPermission,
|
||||
e(404, 'Article not found'),
|
||||
e(500, 'Failed to delete article'),
|
||||
),
|
||||
),
|
||||
NO_CONTENT_RESPONSE,
|
||||
{ query: [FORMAT_QUERY] },
|
||||
),
|
||||
endpoint(
|
||||
'art-check',
|
||||
'POST',
|
||||
|
||||
@@ -1666,6 +1666,8 @@ export const messages = {
|
||||
'Publish an article.': 'Publie un article.',
|
||||
'Unpublish an article.': 'Dépublie un article.',
|
||||
'Delete an article.': 'Supprime un article.',
|
||||
'Delete a whole article: every version of the story, readable or not, in one step. Needs the permission to delete content at scope all.':
|
||||
'Supprime un article entier : toutes ses versions, lisibles ou non, en une seule fois. Demande la permission de supprimer du contenu sur la portée « tout ».',
|
||||
'List images.': 'Liste les images.',
|
||||
'Upload an image (multipart).': 'Téléverse une image (multipart).',
|
||||
'Rename an image. The URL stays the same.': 'Renomme une image. L’URL reste la même.',
|
||||
|
||||
@@ -65,9 +65,9 @@ describe('index', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('holds 91 endpoints: 45 documented, 40 session only and 6 public auth', () => {
|
||||
expect(index.endpoints).toHaveLength(91)
|
||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(45)
|
||||
it('holds 92 endpoints: 46 documented, 40 session only and 6 public auth', () => {
|
||||
expect(index.endpoints).toHaveLength(92)
|
||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(46)
|
||||
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
|
||||
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
|
||||
})
|
||||
@@ -202,7 +202,7 @@ describe('matching', () => {
|
||||
describe('custom content types', () => {
|
||||
it.each([
|
||||
['media', 16, 1],
|
||||
['404', 26, 1],
|
||||
['404', 27, 1],
|
||||
['login', 1, 1],
|
||||
['entry', 7, 1],
|
||||
['capacity', 2, 0],
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||
import {
|
||||
altHelp,
|
||||
breadcrumbOf,
|
||||
canonicalNote,
|
||||
descriptionBadge,
|
||||
descriptionHelp,
|
||||
disabledNote,
|
||||
pageLanguage,
|
||||
socialLocaleLine,
|
||||
socialLocaleNote,
|
||||
titleHelp,
|
||||
type LocalizedSeoContext,
|
||||
} from '../localizedSeo'
|
||||
|
||||
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
|
||||
|
||||
function resolved(
|
||||
fallbacks: Partial<ResolvedSeo['fallbacks']> = {},
|
||||
locale = 'fr_FR',
|
||||
): ResolvedSeo {
|
||||
return {
|
||||
title: 'Couts de renovation | Journal',
|
||||
description: '',
|
||||
canonical_url: 'https://journal.example.com/fr/couts',
|
||||
robots: 'index, follow',
|
||||
og: {
|
||||
type: 'article',
|
||||
title: '',
|
||||
description: '',
|
||||
image: '',
|
||||
image_alt: '',
|
||||
url: '',
|
||||
site_name: 'Journal',
|
||||
locale,
|
||||
},
|
||||
twitter: { card: 'summary', title: '', description: '', image: '', image_alt: '' },
|
||||
image: { url: '', alt: '', source: 'none' },
|
||||
authors: [],
|
||||
published_at: null,
|
||||
modified_at: '2026-09-26T11:02:00Z',
|
||||
fallbacks: {
|
||||
title: 'title',
|
||||
description: 'none',
|
||||
image: 'none',
|
||||
canonical: 'pattern',
|
||||
...fallbacks,
|
||||
},
|
||||
json_ld: {},
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<LocalizedSeoContext> = {}): LocalizedSeoContext {
|
||||
return {
|
||||
lang: 'fr',
|
||||
siteName: 'Journal',
|
||||
siteKey: 'journal',
|
||||
pattern: PATTERN,
|
||||
defaultLang: 'en',
|
||||
disabledLangs: [],
|
||||
published: true,
|
||||
socialState: 'derived',
|
||||
resolved: resolved(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Page language and breadcrumb', () => {
|
||||
it('names the version language in the interface language', () => {
|
||||
expect(pageLanguage(context())).toBe('French')
|
||||
setLocale('fr')
|
||||
expect(pageLanguage(context())).toBe('Français')
|
||||
})
|
||||
|
||||
it('turns a URL into host › segments and keeps anything else as is', () => {
|
||||
expect(breadcrumbOf('https://journal.example.com/fr/couts')).toBe(
|
||||
'journal.example.com › fr › couts',
|
||||
)
|
||||
expect(breadcrumbOf('not a url')).toBe('not a url')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canonical note', () => {
|
||||
it('says the canonical is built from the Public URL pattern', () => {
|
||||
expect(canonicalNote(context())).toBe(
|
||||
`Built from the Public URL pattern ${PATTERN}. Each language points to itself unless you set a canonical override.`,
|
||||
)
|
||||
})
|
||||
|
||||
it('says a canonical override was set by hand', () => {
|
||||
expect(canonicalNote(context({ resolved: resolved({ canonical: 'canonical_url' }) }))).toBe(
|
||||
'Set by hand as a canonical override on this version.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled languages note', () => {
|
||||
it('leaves out drafts only when no language is disabled', () => {
|
||||
expect(disabledNote(context())).toBe('Drafts are left out.')
|
||||
})
|
||||
|
||||
it('names one disabled language', () => {
|
||||
expect(disabledNote(context({ disabledLangs: ['es'] }))).toBe(
|
||||
'Drafts and the disabled Spanish language are left out.',
|
||||
)
|
||||
})
|
||||
|
||||
it('lists several disabled languages', () => {
|
||||
expect(disabledNote(context({ disabledLangs: ['es', 'de'] }))).toBe(
|
||||
'Drafts and the disabled languages Spanish, German are left out.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Social locale', () => {
|
||||
it('reads Derived for a locale taken from the language code', () => {
|
||||
const derived = context()
|
||||
expect(socialLocaleLine(derived)).toBe('Derived: fr_FR')
|
||||
expect(socialLocaleNote(derived)).toBe(
|
||||
'Taken from the region of the language code or of the site locale. An owner can set another in Journal languages.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads Set for a locale an owner chose', () => {
|
||||
const set = context({ socialState: 'set' })
|
||||
expect(socialLocaleLine(set)).toBe('Set: fr_FR')
|
||||
expect(socialLocaleNote(set)).toBe(
|
||||
'Set by an owner for French in Journal languages. A region is never guessed from the language.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads Social locale not set when nothing resolves', () => {
|
||||
const unset = context({ resolved: resolved({}, '') })
|
||||
expect(socialLocaleLine(unset)).toBe('Social locale not set')
|
||||
expect(socialLocaleNote(unset)).toBe(
|
||||
'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.',
|
||||
)
|
||||
expect(socialLocaleLine(context({ resolved: null }))).toBe('Social locale not set')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field help follows the fallbacks of the API', () => {
|
||||
it('names the title fallback only when the title is used', () => {
|
||||
expect(titleHelp(context())).toBe('Fallback: French title')
|
||||
expect(titleHelp(context({ resolved: resolved({ title: 'seo_title' }) }))).toBe('')
|
||||
expect(titleHelp(context({ resolved: null }))).toBe('')
|
||||
})
|
||||
|
||||
it('names each description fallback', () => {
|
||||
const help = (description: string, siteTextSkipped = false) =>
|
||||
descriptionHelp(
|
||||
context({
|
||||
resolved: resolved({ description, site_text_skipped: siteTextSkipped }),
|
||||
}),
|
||||
)
|
||||
expect(help('excerpt')).toBe('Fallback: French excerpt')
|
||||
expect(help('body')).toBe('Fallback: French body')
|
||||
expect(help('site')).toBe('Fallback: French site description')
|
||||
expect(help('none')).toBe('No fallback')
|
||||
expect(help('none', true)).toBe(
|
||||
'No fallback. Empty. English site default not used. The tag is left out.',
|
||||
)
|
||||
expect(help('seo_description')).toBe('')
|
||||
expect(descriptionHelp(context({ resolved: null }))).toBe('')
|
||||
})
|
||||
|
||||
it('badges the site text and the withheld tag', () => {
|
||||
expect(descriptionBadge(context({ resolved: resolved({ description: 'site' }) }))).toBe(
|
||||
'Uses site text',
|
||||
)
|
||||
expect(
|
||||
descriptionBadge(
|
||||
context({ resolved: resolved({ description: 'none', site_text_skipped: true }) }),
|
||||
),
|
||||
).toBe('Tag left out')
|
||||
expect(descriptionBadge(context({ resolved: resolved({ description: 'excerpt' }) }))).toBe(
|
||||
'',
|
||||
)
|
||||
expect(descriptionBadge(context({ resolved: null }))).toBe('')
|
||||
})
|
||||
|
||||
it('says the image alt is written per language', () => {
|
||||
expect(altHelp()).toBe('Written per language.')
|
||||
})
|
||||
})
|
||||
@@ -40,14 +40,14 @@ afterEach(async () => {
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function backendFor(viewer: StoryViewer = {}, reader = false): MetaBackend {
|
||||
function backendFor(viewer: StoryViewer = {}, reader = false, defaultKey = 'journal'): MetaBackend {
|
||||
const [first] = renewSites()
|
||||
const sites = ['journal', 'atelier', 'press'].map((key, index) => ({
|
||||
...first!,
|
||||
id: index + 1,
|
||||
key,
|
||||
name: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
is_default: index === 0,
|
||||
is_default: key === defaultKey,
|
||||
}))
|
||||
const backend = createMetaBackend({ sites })
|
||||
if (reader) {
|
||||
@@ -584,6 +584,17 @@ describe('URL state', () => {
|
||||
expect(first.get('tab')).toBe(expected.tab ?? null)
|
||||
})
|
||||
|
||||
it('lists every site and takes the filter options from the default site when the URL names no site', async () => {
|
||||
const backend = backendFor({}, false, 'atelier')
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(listCalls(backend).every((call) => !call.query.has('site'))).toBe(true)
|
||||
expect(callsTo(backend, 'GET', '/v1/site/atelier/languages')).not.toHaveLength(0)
|
||||
expect(callsTo(backend, 'GET', '/v1/site/journal/languages')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops a language the site does not have once the languages are known', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&lang=pt', { backend })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import router from '@/router'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import { announce } from '@/composables/useLiveAnnouncement'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { settle, text, unmountRoute } from './localizationKit'
|
||||
import {
|
||||
@@ -22,7 +23,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => initializeLocale())
|
||||
beforeEach(() => {
|
||||
initializeLocale()
|
||||
sessionStorage.clear()
|
||||
announce('')
|
||||
})
|
||||
afterEach(unmountRoute)
|
||||
|
||||
const LANGUAGE_NAMES = ['English', 'Arabic', 'French', 'German']
|
||||
@@ -189,6 +194,35 @@ describe('The menu is keyboard operable', () => {
|
||||
expect(document.activeElement?.textContent).toContain('French')
|
||||
})
|
||||
|
||||
it('opens on Enter with focus on the current row', async () => {
|
||||
await mountEditor()
|
||||
languageTrigger()!.focus()
|
||||
languageTrigger()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
)
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
|
||||
expect(document.activeElement?.textContent).toContain('French')
|
||||
})
|
||||
|
||||
it('switches to the version chosen with Enter', async () => {
|
||||
await mountEditor()
|
||||
languageTrigger()!.focus()
|
||||
languageTrigger()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
)
|
||||
await settle()
|
||||
const english = rowFor('English')!
|
||||
english.focus()
|
||||
english.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/article/10/edit')
|
||||
expect(document.body.querySelector('[role="menu"]')).toBeNull()
|
||||
expect(titleInput().value).toBe('Renovation costs')
|
||||
})
|
||||
|
||||
it('focuses the current row when the sheet opens on a phone', async () => {
|
||||
await mountEditor({ width: MOBILE_WIDTH })
|
||||
await openLanguageMenu()
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from './localizationKit'
|
||||
import { EDIT_ONLY, READ, articleOf, editBody, mountEditor, titleInput } from './editorKit'
|
||||
import { removeStoredDraftsOf } from '@/lib/storedDraft'
|
||||
import { ApiError, redirectToLogin } from '@/composables/useApi'
|
||||
import router from '@/router'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -53,6 +55,33 @@ describe('A 401 on save mirrors the draft for this user and article', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('A 401 on save sends the writer to log in and keeps the draft for after', () => {
|
||||
it('redirects to /login with the editor as redirect and reason expired, then restores the draft', async () => {
|
||||
const expireSession = () => {
|
||||
redirectToLogin()
|
||||
throw new ApiError(401)
|
||||
}
|
||||
await mountEditor({ extra: { 'PATCH /v1/article/12': expireSession } })
|
||||
await typeInto(titleInput(), 'Couts reels')
|
||||
await editBody('<p>Edited body</p>')
|
||||
await press('Save')
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
redirect: '/article/12/edit',
|
||||
reason: 'expired',
|
||||
})
|
||||
|
||||
await unmountRoute()
|
||||
await mountEditor()
|
||||
|
||||
expect(titleInput().value).toBe('Couts reels')
|
||||
expect(editorBody()).toBe('<p>Edited body</p>')
|
||||
expect(text()).toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l11.restored: Restoring after login', () => {
|
||||
it('shows the stored title and body, not the loaded ones, with the info alert and unsaved state', async () => {
|
||||
store()
|
||||
|
||||
@@ -382,11 +382,11 @@ describe('8d More endpoints than the cap', () => {
|
||||
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
|
||||
'Section Errors. Status codes and messages',
|
||||
)
|
||||
expect(document.body.textContent).toContain('27 results')
|
||||
expect(document.body.textContent).toContain('28 results')
|
||||
expect(document.body.textContent).toContain(
|
||||
'20 more endpoints. Keep typing to narrow the list.',
|
||||
'21 more endpoints. Keep typing to narrow the list.',
|
||||
)
|
||||
expect(liveRegion()).toBe('27 results, showing 7')
|
||||
expect(liveRegion()).toBe('28 results, showing 7')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('Default, every card collapsed', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['articles', 'Articles', '10 routes', 10],
|
||||
['articles', 'Articles', '11 routes', 11],
|
||||
['seo', 'Search and sharing', '2 routes', 2],
|
||||
['ctypes', 'Content types', '5 routes', 5],
|
||||
['entries', 'Entries', '5 routes', 5],
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { join, resolve } from 'node:path'
|
||||
import { build, preview } from 'vite'
|
||||
import { chromium, type Browser, type Locator, type Page } from 'playwright'
|
||||
import { listApi } from './articleListStates'
|
||||
import { visualStates, type ApiMock, type VisualState } from './states'
|
||||
|
||||
interface TouchTarget {
|
||||
name: string
|
||||
find: (page: Page) => Locator
|
||||
todo?: string
|
||||
}
|
||||
|
||||
interface TouchCheck {
|
||||
label: string
|
||||
setup: VisualState
|
||||
targets: TouchTarget[]
|
||||
}
|
||||
|
||||
const MOBILE = { width: 390, height: 844 }
|
||||
const MIN_TARGET_PX = 44
|
||||
const SETTLE_MS = 400
|
||||
|
||||
const webRoot = resolve(import.meta.dirname, '..')
|
||||
const distRoot = join(webRoot, '..', 'tmp', 'visual', 'touch-dist')
|
||||
const configFile = join(webRoot, 'vite.config.ts')
|
||||
|
||||
const shellApi: Record<string, ApiMock> = {
|
||||
'GET /v1/content-type': { status: 200, body: [] },
|
||||
}
|
||||
|
||||
const editorStates = visualStates['localization-editor'] ?? {}
|
||||
|
||||
function editorState(name: string) {
|
||||
const setup = editorStates[name]
|
||||
if (!setup) {
|
||||
throw new Error(`No localization-editor setup named ${name}`)
|
||||
}
|
||||
return setup
|
||||
}
|
||||
|
||||
const byRole = (role: 'button' | 'menuitem', name: string | RegExp) => (page: Page) =>
|
||||
page.getByRole(role, { name, exact: typeof name === 'string' })
|
||||
|
||||
const checks: TouchCheck[] = [
|
||||
{
|
||||
label: 'Editor header (loc.l5.default)',
|
||||
setup: editorState('loc.l5.default'),
|
||||
targets: [
|
||||
{ name: 'Language switcher', find: byRole('button', /Switch version$/) },
|
||||
{ name: 'Save', find: byRole('button', 'Save') },
|
||||
{ name: 'Publish French', find: byRole('button', 'Publish French') },
|
||||
{ name: 'More actions', find: byRole('button', 'More actions') },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Language sheet rows (loc.l5.menu)',
|
||||
setup: editorState('loc.l5.menu'),
|
||||
targets: ['English', 'French', 'German', 'Arabic'].map((language) => ({
|
||||
name: `${language} row`,
|
||||
find: byRole('menuitem', new RegExp(`^${language}`)),
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Article list coverage overflow chip (loc.l7.long)',
|
||||
setup: {
|
||||
path: '/article?site=atelier&status=draft&seo=suggestions&size=5',
|
||||
api: listApi(),
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
name: '+N chip',
|
||||
find: byRole('button', /more languages?:/),
|
||||
todo: 'components.md wants a 44 px tap area at 390 px; StoryCoverage draws the 22 px chip with no larger hit area (Phase 4)',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function serveApp() {
|
||||
await build({
|
||||
configFile,
|
||||
root: webRoot,
|
||||
logLevel: 'warn',
|
||||
build: { outDir: distRoot, emptyOutDir: true },
|
||||
})
|
||||
const server = await preview({
|
||||
configFile,
|
||||
root: webRoot,
|
||||
logLevel: 'warn',
|
||||
build: { outDir: distRoot },
|
||||
preview: { port: 4190, strictPort: false, open: false },
|
||||
})
|
||||
const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '')
|
||||
if (!origin) {
|
||||
throw new Error('Vite preview did not report a local URL')
|
||||
}
|
||||
return { origin, close: () => server.close() }
|
||||
}
|
||||
|
||||
function fulfilment(mock: ApiMock, url: URL, call: number) {
|
||||
const body = mock.bodyFor ? mock.bodyFor(url, call) : mock.body
|
||||
const text = typeof body === 'string'
|
||||
return {
|
||||
status: mock.status,
|
||||
headers: { ...mock.headers, ...mock.headersFor?.(url, call) },
|
||||
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
|
||||
body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
async function openState(browser: Browser, origin: string, setup: VisualState) {
|
||||
const context = await browser.newContext({ viewport: MOBILE, reducedMotion: 'reduce' })
|
||||
await context.route(
|
||||
(url) => url.hostname.startsWith('api.') && url.hostname !== new URL(origin).hostname,
|
||||
(route) => route.fulfill({ status: 404, body: '' }),
|
||||
)
|
||||
const calls = new Map<string, number>()
|
||||
await context.route('**/v1/**', async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const key = `${request.method()} ${url.pathname}`
|
||||
const call = (calls.get(key) ?? 0) + 1
|
||||
calls.set(key, call)
|
||||
const mock = setup.api?.[key] ?? shellApi[key]
|
||||
if (!mock) {
|
||||
await route.fulfill({ status: 404, body: '' })
|
||||
return
|
||||
}
|
||||
if (mock.hang) {
|
||||
return
|
||||
}
|
||||
await route.fulfill(fulfilment(mock, url, call))
|
||||
})
|
||||
const page = await context.newPage()
|
||||
await page.goto(`${origin}${setup.path}`)
|
||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
|
||||
)
|
||||
await setup.steps?.(page)
|
||||
await page.waitForTimeout(SETTLE_MS)
|
||||
return page
|
||||
}
|
||||
|
||||
async function hitHeight(target: Locator) {
|
||||
return target.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const x = box.left + box.width / 2
|
||||
const middle = box.top + box.height / 2
|
||||
const hits = (y: number) => {
|
||||
const found = document.elementFromPoint(x, y)
|
||||
return found !== null && (found === element || element.contains(found))
|
||||
}
|
||||
let top = middle
|
||||
while (top > middle - 40 && hits(top - 1)) {
|
||||
top -= 1
|
||||
}
|
||||
let bottom = middle
|
||||
while (bottom < middle + 40 && hits(bottom + 1)) {
|
||||
bottom += 1
|
||||
}
|
||||
return { box: box.height, hit: bottom - top + 1 }
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const app = await serveApp()
|
||||
const browser = await chromium.launch()
|
||||
const lines: string[] = []
|
||||
let failures = 0
|
||||
let todos = 0
|
||||
try {
|
||||
for (const check of checks) {
|
||||
const page = await openState(browser, app.origin, check.setup)
|
||||
for (const target of check.targets) {
|
||||
const located = target.find(page).first()
|
||||
if ((await located.count()) === 0) {
|
||||
failures++
|
||||
lines.push(`FAIL ${check.label} · ${target.name}: not found`)
|
||||
continue
|
||||
}
|
||||
const { box, hit } = await hitHeight(located)
|
||||
const height = Math.max(Math.round(box), hit)
|
||||
const pass = height >= MIN_TARGET_PX
|
||||
const measured = `box ${box.toFixed(1)} px, tap area ${hit} px`
|
||||
if (target.todo && !pass) {
|
||||
todos++
|
||||
lines.push(`TODO ${check.label} · ${target.name}: ${measured} (${target.todo})`)
|
||||
continue
|
||||
}
|
||||
if (!pass) {
|
||||
failures++
|
||||
}
|
||||
lines.push(`${pass ? 'PASS' : 'FAIL'} ${check.label} · ${target.name}: ${measured}`)
|
||||
}
|
||||
await page.context().close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
await app.close()
|
||||
}
|
||||
console.log(lines.join('\n'))
|
||||
console.log(
|
||||
failures
|
||||
? `FAIL: ${failures} touch targets under ${MIN_TARGET_PX} px at 390 px`
|
||||
: `PASS: every checked touch target is at least ${MIN_TARGET_PX} px high at 390 px${todos ? `, ${todos} known gap${todos > 1 ? 's' : ''} listed as TODO` : ''}`,
|
||||
)
|
||||
if (failures) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
Reference in new issue
Block a user