diff --git a/scripts/editorContextMenuI18n.test.ts b/scripts/editorContextMenuI18n.test.ts index 2c38f3f..cdb78b1 100644 --- a/scripts/editorContextMenuI18n.test.ts +++ b/scripts/editorContextMenuI18n.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -import { getSupportedLanguages, t, type LanguageCode } from '../src/lib/utils/i18n.js'; +import { getSupportedLanguages, t, translations, type LanguageCode, type Translation } from '../src/lib/utils/i18n.js'; // WHAT THIS FILE COVERS, AND WHAT IT DOES NOT // @@ -24,6 +24,33 @@ function count(source: string, pattern: RegExp): number { return source.match(pattern)?.length ?? 0; } +/** + * Does `lang`'s own dictionary define `key`, without the English fallback? + * + * `t()` falls back to English, so `assert.notEqual(t(key, lang), key)` can only + * fail when ENGLISH lacks the key — which each caller below already asserts one + * line earlier. The 26-language loop around it restated that 26 times and could + * not see the regression it was written for: deleting the German + * `menu.inlineCode` entry left the whole suite green while the German context + * menu rendered the English label. + * + * These labels are held to a stricter bar than the dictionary at large. + * i18nCoverage.test.ts reports per-locale gaps rather than failing on them, + * because 19 locales are missing >100 keys and nobody should have to translate + * a new English string 25 times before landing it. That trade does not apply + * here: this is a fixed list of a dozen context-menu entries, all 26 languages + * define all of them today, and adding an eleventh action means adding a row to + * FORMATTING_ACTIONS by hand — so the cost is visible where it is incurred. + */ +function defines(lang: LanguageCode, key: string): boolean { + let node: string | Translation | undefined = translations[lang]; + for (const part of key.split('.')) { + if (typeof node !== 'object' || node === null || !(part in node)) return false; + node = node[part]; + } + return typeof node === 'string'; +} + // The markdown formatting entries that used to be English string literals. const FORMATTING_ACTIONS: ReadonlyArray<[actionId: string, key: string, englishLabel: string]> = [ ['fmt-inline-code', 'menu.inlineCode', 'Inline Code'], @@ -60,13 +87,16 @@ test('every context-menu label is translated, none is an English literal', () => ); }); -test('the formatting labels exist in English and resolve in every language', () => { +test('the formatting labels exist in English and are translated in every language', () => { for (const [, key, englishLabel] of FORMATTING_ACTIONS) { assert.equal(t(key, 'en'), englishLabel, `${key} is defined for English`); for (const lang of supported) { - const label = t(key, lang as LanguageCode); - assert.notEqual(label, key, `${key} resolves for ${lang} instead of echoing the key`); - assert.ok(label.length > 0, `${key} is non-empty for ${lang}`); + // `defines`, not `t(...) !== key` — see the note on the helper. + assert.ok( + defines(lang as LanguageCode, key), + `${key} is translated for ${lang}; it currently falls back to the English label`, + ); + assert.ok(t(key, lang as LanguageCode).length > 0, `${key} is non-empty for ${lang}`); } } }); @@ -92,7 +122,11 @@ test('toggle-occurrences-highlight has its own label, not Show Whitespace', () = for (const lang of supported) { const occurrences = t('settings.occurrencesHighlight', lang as LanguageCode); const whitespace = t('settings.showWhitespace', lang as LanguageCode); - assert.notEqual(occurrences, 'settings.occurrencesHighlight', `defined for ${lang}`); + // Same substitution as above: `occurrences !== 'settings.occurrencesHighlight'` + // only ever failed if English lacked the key, which the assertions at the + // top of this test already cover. + assert.ok(defines(lang as LanguageCode, 'settings.occurrencesHighlight'), `translated for ${lang}`); + assert.ok(defines(lang as LanguageCode, 'settings.showWhitespace'), `translated for ${lang}`); assert.notEqual( occurrences, whitespace, @@ -156,13 +190,13 @@ test('actions are re-registered when the UI language changes', () => { ); assert.match(editor, /onDestroy\(disposeLocalizedActions\)/, 'teardown releases them too'); - // The old design read a `uiLanguage` snapshot captured inside onMount. + // The old design read a `uiLanguage` snapshot captured inside onMount. The + // narrower "no label is still bound to the snapshot" count that used to + // follow this line is gone: it counted occurrences of a pattern containing + // `uiLanguage`, so the line above has to pass for it to be reached and, + // once it has, the count is zero by construction. There is no state of the + // component in which it, and not the line above, is the failure. assert.doesNotMatch(editor, /uiLanguage/, 'no captured language snapshot remains'); - assert.equal( - count(editor, /label: t\('[^']+', uiLanguage\)/g), - 0, - 'no label is still bound to the snapshot', - ); }); test('no action id is registered more than once', () => { diff --git a/scripts/previewSanitize.test.ts b/scripts/previewSanitize.test.ts index b21ced0..9e9866d 100644 --- a/scripts/previewSanitize.test.ts +++ b/scripts/previewSanitize.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { MARKDOWN_SANITIZE_CONFIG, ALLOWED_MARKDOWN_URI_REGEXP } from '../src/lib/utils/sanitize.js'; -import { SANITIZER_FILES, callSiteOffsets, enclosingFunctionName, filesMatching, readSourceFiles } from './sourceTree.js'; +import { SANITIZER_FILES, callSiteOffsets, enclosingFunctionName, filesMatching, readSourceFiles, sliceBetween } from './sourceTree.js'; // The preview is the path the ` +// +// It used to be a `POC_STYLE` constant with an `assert.match(POC_STYLE, +// /^'; const SOURCES = readSourceFiles('src'); const viewerSource = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); @@ -62,12 +71,37 @@ test('the preview sanitizes through the shared policy, not a local config', () = 'the viewer must import the shared sanitizer', ); - // The sink itself: every bare `{@html ident}` in the viewer injects the - // sanitizer's output and nothing else. Stated over *all* of them rather than - // over one known-good spelling, so adding a second injection point of the raw - // document is a failure instead of an unnoticed addition. - const injected = [...new Set([...viewerSource.matchAll(/\{@html\s+([A-Za-z_$][\w$]*)\s*\}/g)].map((m) => m[1]))]; - assert.deepEqual(injected, [sanitizedSinkName()], 'the preview sink must inject the shared sanitizer output'); + // The sinks: every `{@html …}` in the viewer, whatever the expression looks + // like. Stated over *all* of them rather than over one known-good spelling, + // so adding an injection point of the raw document is a failure instead of + // an unnoticed addition. + // + // The capture used to be `([A-Za-z_$][\w$]*)`, bare identifiers only, which + // made the claim unfalsifiable in exactly the direction it is about. The + // list it compared was `[sanitizedSinkName()]` by construction: the second + // sink already in the file, `{@html tooltip.html}`, was invisible to it, and + // a planted `{@html unsafe.rawHtml}` beside the sanitized one — the raw + // rendered document, injected — left the whole suite green. + // + // So the set is an allowlist now, and `tooltip.html` is on it with a reason + // rather than by accident: the footnote tooltip is a clone of a subtree of + // `markdownBody`, i.e. of the DOM the sanitized sink already injected. It is + // not a second policy, it is the same bytes read back out of the document — + // which is what the next two assertions pin. + const injected = [...new Set([...viewerSource.matchAll(/\{@html\s+([^}]+?)\s*\}/g)].map((m) => m[1]))].sort(); + assert.deepEqual( + injected, + [sanitizedSinkName(), 'tooltip.html'].sort(), + 'unexpected {@html} sink — what the preview injects is the shared sanitizer output', + ); + + // Why the footnote sink is allowed, pinned as a direction rather than as a + // spelling: the tooltip body is read out of the rendered document, never + // built from a string the sanitizer has not seen. + const footnote = sliceBetween(viewerSource, "anchor.hasAttribute('data-footnote-ref')", 'isFootnote: true'); + assert.match(footnote, /markdownBody\?\.querySelector/, 'the footnote body is found in the rendered document'); + assert.match(footnote, /\.innerHTML/, 'and taken from the DOM the shared sanitizer already filtered'); + assert.doesNotMatch(footnote, /rawContent/, 'never from the unrendered buffer'); // The regression itself — a DOMPurify call with a config assembled at the // call site — is caught for the whole tree by the call-site allowlist below; @@ -88,8 +122,11 @@ test('the shared policy the preview now gets is the one that forbids author styl assert.match(sanitizeSource, /return DOMPurify\.sanitize\(html, MARKDOWN_SANITIZE_CONFIG\)/); assert.deepEqual(Object.keys(MARKDOWN_SANITIZE_CONFIG).sort(), ['ALLOWED_URI_REGEXP', 'FORBID_TAGS']); assert.deepEqual(MARKDOWN_SANITIZE_CONFIG.FORBID_TAGS, ['style']); + // Identity, not equality: the regression this file exists for is a *copy* of + // the URI pattern, and a copy that happens to be spelled the same today is + // the thing that drifts tomorrow. Measured — rebuilding the config's regexp + // from the exported one's source and flags fails here. assert.equal(MARKDOWN_SANITIZE_CONFIG.ALLOWED_URI_REGEXP, ALLOWED_MARKDOWN_URI_REGEXP); - assert.match(POC_STYLE, /^