test(mobile): scope i18n coverage to what this PR lands

Supersedes the UNVERIFIED note on the first commit: the suites have now run.

Three embedded-webview-copy cases asserted that the rich-editor and terminal
WebView documents emit localized, escaped copy. Those documents are not
localized here, so the cases belonged to string extraction, not to this PR.
Replaced with direct coverage of escapeEmbeddedHtmlCopy, including the
ampersand-first ordering that keeps entities from double-decoding.

Also relabels the catalog-registration test. It cannot distinguish lazy from
eager registration while only en.json exists — registering every supported
locale yields an identical result — so it now says what it actually pins: a
supported locale without a catalog is never registered.
This commit is contained in:
Merge Sim
2026-09-07 10:35:12 -07:00
parent 43756f1e7a
commit 50ef7cb59a
2 changed files with 25 additions and 67 deletions
+19 -64
View File
@@ -1,79 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
buildMobileRichMarkdownEditorHtml,
escapeInjectedJavaScriptString
} from '../components/mobile-rich-markdown-editor-html'
import { buildTerminalWebViewHtml } from '../terminal/terminal-webview-html'
import { escapeEmbeddedHtmlCopy } from './embedded-webview-copy'
import { mobileI18n } from './mobile-i18n'
const INITIAL_LOCALE = mobileI18n.language
afterEach(async () => {
await mobileI18n.changeLanguage(INITIAL_LOCALE)
})
describe('embedded WebView copy', () => {
it('escapes translated HTML text and attributes', () => {
// The WebView-injection call sites are not localized yet, so this covers the
// primitive on its own. Extraction adds the cases that assert translated copy
// reaches the rich-editor and terminal documents already escaped.
describe('escapeEmbeddedHtmlCopy', () => {
it('escapes text and attribute delimiters', () => {
expect(escapeEmbeddedHtmlCopy('<Copy "all" & more>')).toBe(
'&lt;Copy &quot;all&quot; &amp; more&gt;'
)
})
it('injects localized rich-editor and terminal controls', async () => {
await mobileI18n.changeLanguage('es')
const editorHtml = buildMobileRichMarkdownEditorHtml()
expect(editorHtml).toContain('<html lang="es">')
expect(editorHtml).toContain('data-placeholder="Empieza a escribir..."')
expect(editorHtml).toContain('window.prompt("URL del enlace")')
expect(editorHtml).toContain('window.prompt("URL de la imagen")')
expect(editorHtml).toContain(`+ "Tarea" +`)
const terminalHtml = buildTerminalWebViewHtml()
expect(terminalHtml).toContain('<html lang="es">')
expect(terminalHtml).toContain('id="sel-menu-copy">Copiar</button>')
expect(terminalHtml).toContain('id="sel-menu-all">Seleccionar todo</button>')
it('neutralizes markup that would otherwise parse as an element', () => {
expect(escapeEmbeddedHtmlCopy('<img src=x onerror=alert(1)>')).toBe(
'&lt;img src=x onerror=alert(1)&gt;'
)
expect(escapeEmbeddedHtmlCopy('<b>Task & more</b>')).toBe('&lt;b&gt;Task &amp; more&lt;/b&gt;')
})
it('escapes script-state delimiters in translated JavaScript strings', () => {
expect(escapeInjectedJavaScriptString('<!--<script>')).toBe('"\\u003c!--\\u003cscript>"')
it('escapes ampersands first so entities are not double-decoded', () => {
expect(escapeEmbeddedHtmlCopy('&lt;')).toBe('&amp;lt;')
})
it('HTML-escapes rich-editor placeholders before insertHTML parses them', () => {
const codePlaceholder = mobileI18n.getResource(
'en',
'translation',
'richMarkdown.codePlaceholder'
)
const taskPlaceholder = mobileI18n.getResource(
'en',
'translation',
'richMarkdown.taskPlaceholder'
)
mobileI18n.addResource(
'en',
'translation',
'richMarkdown.codePlaceholder',
'<img src=x onerror=alert(1)>'
)
mobileI18n.addResource(
'en',
'translation',
'richMarkdown.taskPlaceholder',
'<b>Task & more</b>'
)
it('escapes the single quote that would close an attribute', () => {
expect(escapeEmbeddedHtmlCopy("it's")).toBe('it&#39;s')
})
try {
const editorHtml = buildMobileRichMarkdownEditorHtml()
expect(editorHtml).toContain('&lt;img src=x onerror=alert(1)&gt;')
expect(editorHtml).toContain('&lt;b&gt;Task &amp; more&lt;/b&gt;')
expect(editorHtml).not.toContain('<img src=x onerror=alert(1)>')
expect(editorHtml).not.toContain('<b>Task & more</b>')
} finally {
mobileI18n.addResource('en', 'translation', 'richMarkdown.codePlaceholder', codePlaceholder)
mobileI18n.addResource('en', 'translation', 'richMarkdown.taskPlaceholder', taskPlaceholder)
}
it('leaves copy without markup untouched', () => {
expect(escapeEmbeddedHtmlCopy('Seleccionar todo')).toBe('Seleccionar todo')
expect(escapeEmbeddedHtmlCopy('')).toBe('')
})
})
+6 -3
View File
@@ -83,9 +83,12 @@ describe('mobile i18n startup', () => {
expect(mobileI18n.isInitialized).toBe(true)
})
// A locale change restarts the app, so only the fallback and the active locale are
// ever needed. Registering all five would parse unused catalogs on every boot.
it('registers only the catalogs it has, not every supported locale', () => {
// Coverage, not a regression guard: with only en.json present, registering every
// supported locale produces the same result as registering the active one, so this
// cannot distinguish the two. It pins that a supported locale without a catalog is
// never registered. Extraction adds a second catalog, which is what finally makes
// the lazy path observable.
it('never registers a supported locale that has no catalog', () => {
expect(mobileI18n.hasResourceBundle('en', 'translation')).toBe(true)
for (const locale of ['es', 'ja', 'ko', 'zh'] satisfies MobileUiLocale[]) {
expect(loadMobileLocaleCatalog(locale)).toBeUndefined()