feat(SPA): i18n internationzalization, english and french

This commit is contained in:
fsylvain committed 2026-10-03 01:53:49 +02:00
1 parent 7f1e8e572d
commit 721311a643
88 files changed
+4438 -1148

No files matched your search

+769
View File
@@ -0,0 +1,769 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import LanguagePicker from '@/components/LanguagePicker.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import UserMenu from '@/components/UserMenu.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import { useApi, ApiError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { useColorMode } from '@/composables/useColorMode'
import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { initializeLocale, locale, setLocale, t } from './index'
import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
return {
useApi: vi.fn(),
authRedirectPending: ref(false),
ApiError: class extends Error {
constructor(
public status: number,
public retryAfter?: number,
public body?: string,
) {
super('API failure')
}
},
}
})
let wrapper: VueWrapper | undefined
const request = vi.fn()
beforeEach(() => {
localStorage.clear()
initializeLocale()
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
useSessionStore().status = 'ready'
request.mockReset().mockResolvedValue([])
vi.mocked(useApi).mockReturnValue({
request,
getMe: vi.fn().mockResolvedValue(null),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(async () => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
localStorage.clear()
initializeLocale()
await router.push('/login')
})
async function mountRoute(path: string) {
const session = useSessionStore()
session.session =
path === '/login'
? null
: {
id: 1,
verified: true,
username: 'marta',
displayName: 'Marta',
email: 'marta@example.com',
role: 'owner',
}
session.status = 'ready'
await router.push(path)
wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
return wrapper
}
function trigger() {
return document.body.querySelector<HTMLButtonElement>(
'[role="combobox"][aria-label="Language"], [role="combobox"][aria-label="Langue"]',
)!
}
function key(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: value,
bubbles: true,
cancelable: true,
}),
)
}
describe('Translate the entire interface', () => {
it('provides nonempty equivalent interpolation parameters for every English and French key', () => {
const placeholders = (text: string) =>
[...text.matchAll(/\{\d+\}/g)].map((match) => match[0]).sort()
expect(
Object.entries(messages).filter(
([english, french]) =>
!french.trim() ||
JSON.stringify(placeholders(english)) !== JSON.stringify(placeholders(french)),
),
).toEqual([])
})
it('formats French dates, decimal sizes and plural counts', () => {
setLocale('fr')
expect([
formatAbsoluteDateFromIso('2025-02-14T12:00:00Z'),
formatSize(1572864),
pluralArticles(1234),
]).toEqual(['14 févr. 2025', '1,5 Mo', '1 234 articles'])
})
it('falls back to the English key for an unavailable French message', () => {
setLocale('fr')
expect(t('Unavailable message' as keyof typeof messages)).toBe('Unavailable message')
})
})
describe('Switch language without losing work', () => {
it('retranslates added and retried bulk failures while preserving filenames and retry targets', async () => {
let items = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp'].map(
(filename, index) => ({
id: index + 1,
filename,
url: `/media/${filename}`,
size: 1024,
content_type: 'image/jpeg',
width: 100,
height: 100,
sha256: filename,
uploaded_by: 'Marta',
created_at: '2025-02-14T12:00:00Z',
used_in: 0,
duplicate: false,
}),
)
let retry = false
request.mockImplementation(async (path: string, options?: RequestInit) => {
if (options?.method === 'DELETE') {
const id = Number(path.substring(path.lastIndexOf('/') + 1))
if (id === 3 || (id === 2 && !retry)) throw new ApiError(500)
items = items.filter((item) => item.id !== id)
return undefined
}
return {
items,
next_cursor: null,
counts: { all: items.length, unused: items.length, duplicates: 0 },
}
})
await mountRoute('/media')
for (const item of items) {
document.body
.querySelector<HTMLElement>(
`[role="checkbox"][aria-label="Select ${item.filename}"]`,
)!
.click()
}
await flushPromises()
document.body.querySelector<HTMLButtonElement>('[data-testid="bulk-delete"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((button) => button.textContent?.trim() === 'Delete 3 images')!
.click()
await flushPromises()
const snapshots: boolean[] = []
async function captureBothWays(english: string, french: string) {
for (const [language, description, label] of [
['en', english, 'Retry'],
['fr', french, 'Réessayer'],
['en', english, 'Retry'],
] as const) {
setLocale(language)
await flushPromises()
snapshots.push(
document.body.textContent!.includes(description) &&
Array.from(document.body.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === label,
),
)
}
}
await captureBothWays(
'1 image was deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.',
'1 image a été supprimée. kitchen-after.webp et kitchen-before.webp sont toujours dans votre bibliothèque. Réessayez.',
)
retry = true
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.trim() === 'Retry')!
.click()
await flushPromises()
await captureBothWays(
'2 images were deleted. kitchen-before.webp is still in your library. Try again.',
'2 images ont été supprimées. kitchen-before.webp est toujours dans votre bibliothèque. Réessayez.',
)
expect({
snapshots,
deleted: request.mock.calls
.filter(([, options]) => options?.method === 'DELETE')
.map(([path]) => path),
}).toEqual({
snapshots: [true, true, true, true, true, true],
deleted: ['/v1/media/1', '/v1/media/2', '/v1/media/3', '/v1/media/2', '/v1/media/3'],
})
})
it.each(['fallback', 'French catalog error'])(
'updates an active confirmation %s when switching both ways',
async (failure) => {
wrapper = mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Delete',
description: 'Confirm deletion',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting',
tone: 'danger',
action: async () => {
if (failure === 'French catalog error') {
setLocale('fr')
throw new Error(t('Something went wrong. Try again.'))
}
throw null
},
},
global: { plugins: [ui] },
})
await flushPromises()
Array.from(
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
)
.find((button) => button.textContent?.trim() === 'Delete')!
.click()
await flushPromises()
setLocale('en')
await flushPromises()
const english = document.body.querySelector('[role="alert"]')?.textContent?.trim()
setLocale('fr')
await flushPromises()
const french = document.body.querySelector('[role="alert"]')?.textContent?.trim()
setLocale('en')
await flushPromises()
expect([
english,
french,
document.body.querySelector('[role="alert"]')?.textContent?.trim(),
]).toEqual([
'Something went wrong. Try again.',
'Une erreur est survenue. Réessayez.',
'Something went wrong. Try again.',
])
},
)
it('switches both ways while preserving profile draft, route, session, theme and request count', async () => {
await mountRoute('/profile')
const input = wrapper!.find('input[autocomplete="off"]')
await input.setValue('Unsaved name')
const session = JSON.stringify(useSessionStore().session)
const theme = useColorMode().preference
const count = request.mock.calls.length
setLocale('fr')
await flushPromises()
const french = document.body.textContent!.includes('Nom d’affichage')
setLocale('en')
await flushPromises()
expect({
french,
draft: (input.element as HTMLInputElement).value,
path: router.currentRoute.value.path,
session: JSON.stringify(useSessionStore().session),
theme: useColorMode().preference,
requests: request.mock.calls.length,
english: document.body.textContent!.includes('Display name'),
}).toEqual({
french: true,
draft: 'Unsaved name',
path: '/profile',
session,
theme,
requests: count,
english: true,
})
})
it('localizes profile save failure and retains the user draft', async () => {
setLocale('fr')
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Name stays')
request.mockRejectedValueOnce(new ApiError(500, undefined, 'private backend error'))
await wrapper!.find('form').trigger('submit')
await flushPromises()
expect({
alert: document.body.querySelector('[role="alert"]')?.textContent?.trim(),
draft: (wrapper!.find('input[autocomplete="off"]').element as HTMLInputElement).value,
}).toEqual({
alert: 'Impossible d’enregistrer votre nom d’affichage. Votre texte est conservé. Réessayez dans un instant.',
draft: 'Name stays',
})
})
it('updates an already-visible authentication alert and its validation', async () => {
await mountRoute('/login?reason=expired')
await wrapper!.find('form').trigger('submit')
setLocale('fr')
await flushPromises()
expect([
document.body.textContent!.includes('Votre session a expiré'),
document.body.textContent!.includes('Utilisez au moins 3 caractères.'),
document.body.textContent!.includes('Saisissez votre mot de passe.'),
]).toEqual([true, true, true])
})
})
describe('Operate the picker accessibly', () => {
it.each([false, true])(
'supports keyboard selection and focus return with mobile=%s',
async (mobile) => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
props: { mobile },
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
)
const selected = options
.find((option) => option.textContent!.includes('English'))
?.getAttribute('aria-selected')
key(document.activeElement!, 'End')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect({
options: options.map((option) => option.textContent!.trim()),
selected,
locale: locale.value,
closed: !document.body.querySelector('[role="listbox"]'),
focus: document.activeElement === trigger(),
}).toEqual({
options: ['English', 'Français'],
selected: 'true',
locale: 'fr',
closed: true,
focus: true,
})
},
)
it('dismisses with Escape and preserves the language', async () => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
expect([
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual(['en', true, true])
})
it.each(['desktop', 'mobile'])('offers the picker in the %s account menu', async (menu) => {
useSessionStore().session = {
verified: true,
username: 'marta',
displayName: '',
}
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
const profileEntry = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profileEntry.focus()
key(profileEntry, menu === 'desktop' ? 'End' : 'ArrowDown')
await new Promise((resolve) => setTimeout(resolve, 0))
const reachedControl = document.activeElement === trigger()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'End')
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect([
reachedControl,
locale.value,
document.activeElement === trigger(),
trigger().textContent?.includes('Langue'),
]).toEqual([true, 'fr', true, true])
})
it('retains French after logout and renders the public authentication screen in French', async () => {
setLocale('fr')
await mountRoute('/profile')
document.body.querySelector<HTMLButtonElement>('button[title="Compte"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent?.trim() === 'Se déconnecter')!
.click()
await flushPromises()
expect([
router.currentRoute.value.path,
locale.value,
localStorage.getItem('renewcms-language'),
document.body.textContent!.includes('Se connecter'),
]).toEqual(['/login', 'fr', 'fr', true])
})
it('updates an existing success notification without translating the saved account name', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Log in')
request.mockResolvedValueOnce({ display_name: 'Log in' })
await wrapper!.find('form').trigger('submit')
await flushPromises()
setLocale('fr')
await flushPromises()
expect([
document.body.textContent!.includes('Nom d’affichage enregistré'),
document.body.textContent!.includes(
'Vous apparaissez désormais sous le nom Log in dans RenewCMS et les e-mails.',
),
]).toEqual([true, true])
})
it('updates an already-open confirmation dialog', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Unsaved')
const navigation = router.push('/home')
await flushPromises()
setLocale('fr')
await flushPromises()
const translated = document.body
.querySelector('[role="alertdialog"]')
?.textContent?.includes('Quitter sans enregistrer ?')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((button) => button.textContent?.trim() === 'Abandonner les modifications')!
.click()
await navigation
expect(translated).toBe(true)
})
})
describe('Hardener localization outcomes', () => {
it.each([false, true])(
'updates not-found breadcrumbs round-trip with authenticated=%s',
async (authenticated) => {
const { default: NotFoundView } = await import('@/views/NotFoundView.vue')
useSessionStore().session = authenticated
? { verified: true, username: 'marta', displayName: '' }
: null
wrapper = mount(NotFoundView, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(
document.body.querySelector('[role="alert"] nav')?.textContent?.trim(),
)
}
expect(snapshots).toEqual(['Not found', 'Introuvable', 'Not found'])
},
)
it('updates the default error breadcrumb round-trip', async () => {
const { default: ErrorView } = await import('@/components/ErrorView.vue')
wrapper = mount(ErrorView, {
attachTo: document.body,
props: { kind: 'error' },
global: { plugins: [ui, router] },
})
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(wrapper.find('nav').text())
}
expect(snapshots).toEqual(['Home', 'Accueil', 'Home'])
})
it('focuses the invalid username in French', async () => {
setLocale('fr')
await mountRoute('/login')
await wrapper!.find('form').trigger('submit')
await flushPromises()
expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element)
})
it('focuses article search after clearing French no-results', async () => {
request.mockResolvedValue([
{
id: 12,
title: 'Data title',
body: '',
is_online: false,
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
},
])
setLocale('fr')
await mountRoute('/article?q=absent')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((b) => b.textContent?.trim() === 'Effacer la recherche')!
.click()
await flushPromises()
expect([
document.activeElement === document.body.querySelector('input[type="search"]'),
router.currentRoute.value.query.q,
]).toEqual([true, undefined])
})
it('copies the unchanged integration URL with French feedback and protocol examples', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
setLocale('fr')
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
const { default: IntegrationView } = await import('@/views/IntegrationView.vue')
wrapper = mount(IntegrationView, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((b) => b.textContent?.trim() === 'Copier')!
.click()
await flushPromises()
expect([
writeText.mock.calls[0]?.[0],
document.body.textContent?.includes('Copié'),
document.body.textContent?.includes('/v1/article/'),
document.body.textContent?.includes('GET'),
]).toEqual([window.location.origin, true, true, true])
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/')`,
),
)
vi.unstubAllGlobals()
})
it.each(['desktop', 'mobile'] as const)(
'dismisses nested picker with Escape and outside press in %s menu',
async (menu) => {
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
const escape = [
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const outside = document.createElement('button')
outside.textContent = 'Outside'
document.body.append(outside)
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
await flushPromises()
expect([
escape,
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual([['en', true, true], 'en', true, true])
},
)
it.each(['/article', '/article/12/edit'])(
'updates already-open delete descriptions round-trip from %s preserving title and image sentence',
async (path) => {
const article = {
id: 12,
title: 'Log in',
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
is_online: false,
images: [],
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
url === '/v1/article/' ? [article] : article,
)
await mountRoute(path)
if (path === '/article') {
document.body
.querySelector<HTMLButtonElement>('[aria-label="Actions for Log in"]')!
.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((b) => b.textContent?.trim() === 'Delete…')!
.click()
} else
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((b) => b.textContent?.trim() === 'Delete')!
.click()
await flushPromises()
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(document.body.querySelector('[role="alertdialog"]')?.textContent)
}
expect(
snapshots.map((text) => [
text?.includes('Log in'),
text?.includes('will be deleted'),
text?.includes('sera supprimé'),
]),
).toEqual([
[true, true, false],
[true, false, true],
[true, true, false],
])
expect(snapshots[0]).toContain('Its images stay in your library.')
expect(snapshots[1]).toContain('Ses images restent dans votre bibliothèque.')
Array.from(
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
)
.find((b) => b.textContent?.trim() === 'Cancel')!
.click()
await flushPromises()
},
)
it('translates the editor mobile heading', async () => {
request.mockResolvedValue({
id: 12,
title: 'Data title',
body: '',
images: [],
is_online: false,
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
})
setViewportWidth(390)
setLocale('fr')
await mountRoute('/article/12/edit')
expect(document.body.textContent).toContain('Modifier l’article')
})
it('updates a visible 429 upload alert round-trip', async () => {
let upload: LimitedUpload | undefined
const captureUpload = (instance: LimitedUpload) => {
upload = instance
}
class LimitedUpload extends EventTarget {
upload = new EventTarget()
status = 0
responseText = ''
withCredentials = false
open() {}
send() {
captureUpload(this)
}
abort() {}
getResponseHeader() {
return '42'
}
respond() {
this.status = 429
this.dispatchEvent(new Event('load'))
}
}
vi.stubGlobal('XMLHttpRequest', LimitedUpload)
vi.stubGlobal('crypto', { subtle: { digest: async () => new Uint8Array(32).buffer } })
request.mockResolvedValue({
items: [
{
id: 1,
filename: 'existing.jpg',
url: '/media/existing.jpg',
content_type: 'image/jpeg',
size: 1024,
width: 100,
height: 100,
created_at: '2025-02-14T12:00:00Z',
used_in: 0,
sha256: 'abcdef',
uploaded_by: 'Marta',
duplicate: false,
},
],
next_cursor: null,
counts: { all: 1, unused: 1, duplicates: 0 },
})
const listing = await request()
request.mockImplementation(async (path: string) =>
path.includes('?hash=')
? { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
: listing,
)
await mountRoute('/media')
const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
Object.defineProperty(input, 'files', {
value: [new File(['photo'], 'photo.jpg', { type: 'image/jpeg' })],
configurable: true,
})
input.dispatchEvent(new Event('change', { bubbles: true }))
await vi.waitFor(() => expect(upload).toBeDefined())
upload!.respond()
await flushPromises()
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(
Array.from(document.body.querySelectorAll('[role="status"]')).some(
(e) =>
e.textContent?.includes(
language === 'fr' ? 'Trop d’importations' : 'Too many uploads',
) &&
e.textContent.includes(
language === 'fr'
? 'Pour votre sécurité, patientez avant d’importer à nouveau.'
: 'For your security, wait a moment before uploading again.',
),
),
)
}
expect(snapshots).toEqual([true, true, true])
vi.unstubAllGlobals()
})
})