Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions ui/e2e/helpers/gotoApp.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { expect, type Page } from '@playwright/test'
import { formatUnhandled, installApiRoutes, type ApiRouteOptions, type ApiRouteSession } from './apiRoutes'
import { bootWatchdogPlaceholderPath } from './bootWatchdogPlaceholderPath'
import { lockUiLanguage } from './lockUiLanguage'

export async function gotoApp(page: Page, options: ApiRouteOptions = {}): Promise<ApiRouteSession> {
await page.emulateMedia({ reducedMotion: 'reduce' })
const session = await installApiRoutes(page, options)
await page.addInitScript(() => {
window.localStorage.setItem('hocuspocus_welcome_seen_v1', '1')
})
await lockUiLanguage(page, 'en')
// index.html replaces the document after 10s if #root has no element
// children. The production bundle can take longer than that to parse
// on a cold CI runner; keep a placeholder so the watchdog stays inert.
Expand Down
10 changes: 10 additions & 0 deletions ui/e2e/helpers/lockUiLanguage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Page } from '@playwright/test'
import { LANGUAGE_STORAGE_KEY } from '../../src/i18n/storageKey'

/** Pin the real UI language key. `i18nextLng` is ignored by detectUiLanguage. */
export async function lockUiLanguage(page: Page, language: 'en' | 'es' = 'en'): Promise<void> {
await page.addInitScript(({ key, language: value }) => {
window.localStorage.setItem(key, value)
window.localStorage.setItem('hocuspocus_welcome_seen_v1', '1')
}, { key: LANGUAGE_STORAGE_KEY, language })
}
2 changes: 2 additions & 0 deletions ui/e2e/specs/scene-template-review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'
import { expect, test, type Page } from '@playwright/test'
import { closeApp } from '../helpers/gotoApp'
import { installApiRoutes, type ApiRouteSession } from '../helpers/apiRoutes'
import { lockUiLanguage } from '../helpers/lockUiLanguage'
import { CATALOG_VERSION, EXPANDED_CATALOG_VERSION } from '../../src/features/sceneTemplates/catalog'
import { CATALOG_REVIEW_STORAGE_KEY } from '../../src/features/sceneTemplates/catalogReview'
import { candidateDemoScene } from '../../src/features/sceneTemplates/demoScenes'
Expand Down Expand Up @@ -72,6 +73,7 @@ function reviewRouteState(): ReviewRouteState {
async function prepareReviewPage(page: Page): Promise<ApiRouteSession> {
await page.emulateMedia({ reducedMotion: 'reduce' })
const session = await installApiRoutes(page)
await lockUiLanguage(page, 'en')
await page.addInitScript(() => {
const originalSetItem = Storage.prototype.setItem
Storage.prototype.setItem = function setItem(key: string, value: string) {
Expand Down
26 changes: 17 additions & 9 deletions ui/scripts/sceneTemplateReview/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { createHash, randomUUID } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { chromium } from '@playwright/test'
import { LANGUAGE_STORAGE_KEY } from '../../src/i18n/storageKey.ts'
import { animatorLabels } from '../../src/i18n/animatorLabels.ts'

const catalog = await import(new URL('../../src/features/sceneTemplates/catalog.ts', import.meta.url))
const demos = await import(new URL('../../src/features/sceneTemplates/demoScenes.ts', import.meta.url))
const copy = animatorLabels('en')

const renderTimeout = 300_000

Expand Down Expand Up @@ -53,7 +56,7 @@ async function saveEditableScene(page, server, template) {
const responsePromise = page.waitForResponse(response => (
response.url().endsWith('/api/v1/scenes') && response.request().method() === 'POST'
), { timeout: 60_000 })
await page.getByRole('button', { name: 'Save scene', exact: true }).first().click()
await page.getByRole('button', { name: copy.saveScene, exact: true }).first().click()
const response = await responsePromise
assert(response.ok(), `Editable scene save failed for ${template.id}: ${await response.text()}`)
const saved = await response.json()
Expand All @@ -72,7 +75,11 @@ async function saveEditableScene(page, server, template) {
async function renderOne({ browser, server, repoRoot, template, runtime }) {
const source = gitSnapshot(repoRoot)
const scene = demos.candidateDemoScene(template.id, 'coral')
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block' })
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
locale: 'en-US',
serviceWorkers: 'block',
})
const page = await context.newPage()
page.setDefaultTimeout(60_000)
const pageErrors = []
Expand All @@ -82,16 +89,17 @@ async function renderOne({ browser, server, repoRoot, template, runtime }) {
if (requestUrl.origin === server.localOrigin || requestUrl.protocol === 'data:' || requestUrl.protocol === 'blob:') return route.continue()
return route.abort('blockedbyclient')
})
await page.addInitScript(({ snapshot }) => {
localStorage.setItem('i18nextLng', 'en')
await page.addInitScript(({ snapshot, languageKey }) => {
localStorage.setItem(languageKey, 'en')
localStorage.setItem('hocuspocus_welcome_seen_v1', '1')
sessionStorage.setItem('maestro_scene_animator_pending_scene', JSON.stringify(snapshot))
}, { snapshot: scene })
}, { snapshot: scene, languageKey: LANGUAGE_STORAGE_KEY })

try {
await page.goto(`${server.localOrigin}/scene-template-review?editor=1`, { waitUntil: 'domcontentloaded' })
await page.getByLabel('Scene name', { exact: true }).waitFor()
await page.getByLabel('Scene name', { exact: true }).fill(scene.name)
await page.getByLabel('Scene name', { exact: true }).blur()
await page.getByLabel(copy.sceneNameAria, { exact: true }).waitFor()
await page.getByLabel(copy.sceneNameAria, { exact: true }).fill(scene.name)
await page.getByLabel(copy.sceneNameAria, { exact: true }).blur()
await waitForImages(page, scene)
await waitForModels(page, scene)
const savedScene = await saveEditableScene(page, server, template)
Expand All @@ -112,7 +120,7 @@ async function renderOne({ browser, server, repoRoot, template, runtime }) {
response.url().endsWith('/api/v1/scenes/recordings') && response.request().method() === 'POST'
), { timeout: renderTimeout })
console.log(`RENDER ${template.id} ${scene.layers.length} layers`)
await page.getByRole('button', { name: 'Export MP4', exact: true }).click()
await page.getByRole('button', { name: copy.exportMp4, exact: true }).click()
const response = await responsePromise
assert(response.ok(), `MP4 export failed for ${template.id}: ${await response.text()}`)
const saved = await response.json()
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/Sidebar/SceneAnimatorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2167,7 +2167,7 @@ export function SceneAnimatorPanel() {
}
await new Promise(resolve => window.setTimeout(resolve, 250))
}
throw new Error('The 3D models did not paint in time. Keep the 3D Video tab visible and try again.')
throw new Error(t('animator.modelsTimeout'))
}
const applyRecipeScene = async (recipe: SceneRecipe, nextScene: Scene, status: (message: string) => void, prompt: string) => {
recipeContextRef.current = { prompt }
Expand All @@ -2176,7 +2176,7 @@ export function SceneAnimatorPanel() {
status(t('animator.waitingModels'))
await waitForModelViewers()
if (recipe.record !== true && recipe.save !== true) {
status('3D models ready. Scene mounted; press Export MP4 when ready.')
status(t('animator.modelsReady'))
}
if (recipe.record === true) {
status(t('animator.recordingScene'))
Expand Down
7 changes: 7 additions & 0 deletions ui/src/i18n/animatorLabels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import scene3dEn from './locales/en/scene3d.json'
import scene3dEs from './locales/es/scene3d.json'
import type { UiLanguage } from './resources'

export function animatorLabels(language: UiLanguage = 'en') {
return language === 'es' ? scene3dEs.animator : scene3dEn.animator
}
3 changes: 2 additions & 1 deletion ui/src/i18n/language.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { safeStorageGet, safeStorageSet } from '../lib/safeStorage'
import { DEFAULT_LANGUAGE, UI_LANGUAGES, type UiLanguage } from './resources'
import { LANGUAGE_STORAGE_KEY } from './storageKey'

export const LANGUAGE_STORAGE_KEY = 'hocuspocus-ui-language'
export { LANGUAGE_STORAGE_KEY }

export function isUiLanguage(value: string | null | undefined): value is UiLanguage {
return UI_LANGUAGES.includes(value as UiLanguage)
Expand Down
2 changes: 2 additions & 0 deletions ui/src/i18n/locales/en/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,8 @@
"mp4Failed": "Failed to export MP4.",
"recipeLoaded": "Recipe scene loaded: {{name}}",
"waitingModels": "Waiting for 3D models to paint…",
"modelsReady": "3D models ready. Scene mounted; press Export MP4 when ready.",
"modelsTimeout": "The 3D models did not paint in time. Keep the 3D Video tab visible and try again.",
"recordingScene": "Recording scene…",
"convertingMp4": "Converting to MP4 and adding it to Videos…",
"mp4Ready": "MP4 ready in Videos: {{name}}",
Expand Down
2 changes: 2 additions & 0 deletions ui/src/i18n/locales/es/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,8 @@
"mp4Failed": "No se pudo exportar el MP4.",
"recipeLoaded": "Escena de receta cargada: {{name}}",
"waitingModels": "Esperando a que se pinten los modelos 3D…",
"modelsReady": "Modelos 3D listos. Escena montada; pulsa Exportar MP4 cuando quieras.",
"modelsTimeout": "Los modelos 3D no se pintaron a tiempo. Mantén visible la pestaña Vídeo 3D e inténtalo de nuevo.",
"recordingScene": "Grabando escena…",
"convertingMp4": "Convirtiendo a MP4 y añadiéndolo a Vídeos…",
"mp4Ready": "MP4 listo en Vídeos: {{name}}",
Expand Down
2 changes: 2 additions & 0 deletions ui/src/i18n/storageKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Browser localStorage key for the UI language. Do not use `i18nextLng`. */
export const LANGUAGE_STORAGE_KEY = 'hocuspocus-ui-language'
69 changes: 69 additions & 0 deletions ui/tests/sceneAnimatorI18n.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFile } from 'node:fs/promises'
import { JSDOM } from 'jsdom'

function installDom(language = 'en-US') {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' })
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
localStorage: dom.window.localStorage,
})
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { language },
})
}

test('the real UI language key wins over i18nextLng and the browser locale', async () => {
installDom('en-US')
const { detectUiLanguage, LANGUAGE_STORAGE_KEY } = await import('../src/i18n/language.ts')
assert.equal(LANGUAGE_STORAGE_KEY, 'hocuspocus-ui-language')
window.localStorage.setItem('i18nextLng', 'en')
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, 'es')
assert.equal(detectUiLanguage(), 'es')
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en')
Object.defineProperty(window, 'navigator', { configurable: true, value: { language: 'es-ES' } })
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { language: 'es-ES' } })
assert.equal(detectUiLanguage(), 'en')
})

test('animator labels follow the scene3d catalogs, not i18nextLng', async () => {
const { animatorLabels } = await import('../src/i18n/animatorLabels.ts')
assert.equal(animatorLabels('en').sceneNameAria, 'Scene name')
assert.equal(animatorLabels('en').exportMp4, 'Export MP4')
assert.equal(animatorLabels('en').saveScene, 'Save scene')
assert.equal(animatorLabels('es').sceneNameAria, 'Nombre de la escena')
assert.equal(animatorLabels('es').exportMp4, 'Exportar MP4')
assert.equal(animatorLabels('es').saveScene, 'Guardar escena')
})

test('SceneAnimatorPanel surfaces model status through the catalog', async () => {
const panel = await readFile(new URL('../src/components/Sidebar/SceneAnimatorPanel.tsx', import.meta.url), 'utf8')
assert.match(panel, /t\('animator\.modelsReady'\)/)
assert.match(panel, /t\('animator\.modelsTimeout'\)/)
assert.doesNotMatch(panel, /press Export MP4 when ready/)
assert.doesNotMatch(panel, /The 3D models did not paint in time/)
})

test('Playwright boot helpers do not import i18n JSON catalogs', async () => {
const lock = await readFile(new URL('../e2e/helpers/lockUiLanguage.ts', import.meta.url), 'utf8')
const gotoApp = await readFile(new URL('../e2e/helpers/gotoApp.ts', import.meta.url), 'utf8')
assert.match(lock, /storageKey/)
assert.doesNotMatch(lock, /locales\/en/)
assert.doesNotMatch(gotoApp, /locales\/en/)
})

test('the Video3D review renderer locks the real language key and catalog copy', async () => {
const source = await readFile(new URL('../scripts/sceneTemplateReview/render.mjs', import.meta.url), 'utf8')
assert.match(source, /LANGUAGE_STORAGE_KEY/)
assert.match(source, /animatorLabels\('en'\)/)
assert.match(source, /copy\.sceneNameAria/)
assert.match(source, /copy\.exportMp4/)
assert.match(source, /copy\.saveScene/)
assert.match(source, /locale: 'en-US'/)
assert.doesNotMatch(source, /i18nextLng/)
assert.doesNotMatch(source, /getByLabel\('Scene name'/)
assert.doesNotMatch(source, /getByRole\('button', \{ name: 'Export MP4'/)
})
Loading