From 34a9ce10d4645933525919e2f5cf0aff7e28d5b1 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 19 May 2026 21:56:08 +0200 Subject: [PATCH] =?UTF-8?q?test(01-12):=20wave-0=20=E2=80=94=20scaffold=20?= =?UTF-8?q?RED=20unit=20tests=20(tokens=20/=20fonts=20/=20icons=20/=20no-r?= =?UTF-8?q?emote-fonts=20/=20manifest-i18n=20/=20locale-parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 0 of the design-integration plan. Six new test files at tests/build/ and tests/i18n/ pin the contracts that later waves will GREEN: - tokens-adopted.test.ts (4 cases): src/shared/tokens.css exists + parses; src/popup/style.css @imports it; popup/style.css has zero hex literals; welcome.css conditional check. - fonts-present.test.ts: 7 required WOFF2 faces (Lora normal + Plex Sans ×4 + Plex Mono ×2) + LICENSE-Lora + LICENSE-IBM-Plex + README + optional Lora-Italic (A5 verify-at-execute). - icons-present.test.ts (15 cases across 3 sizes): existence, size FLOOR per assets-spec.md, PNG signature, dimensions, color-type byte === 6 (RGBA — RED until Wave 2 rsvg-convert overwrites the 16-bit-RGB placeholders). - no-remote-fonts.test.ts: production dist/ contains zero fonts.googleapis.com / https://fonts / googleapis substrings (MV3 CSP self-host invariant T-01-12-01). - manifest-i18n.test.ts (10 cases): manifest:name === '__MSG_extName__', :description === '__MSG_extDesc__', :default_locale === 'en', :action.default_title === '__MSG_tooltipOff__'; _locales/{en,ru}/ messages.json carry D-07 + D-08 canonical strings. - locale-parity.test.ts (4 cases): ru→en parity, en→ru symmetric, non-empty .message strings (RESEARCH Pitfall 4 mitigation). Current polarity: 29 RED + 18 GREEN across the 6 new files (placeholders already clear dim+size floors; no-remote-fonts vacuous-GREEN since tokens.css doesn't yet exist with remote URLs). Existing 100/100 vitest baseline preserved (verified SKIP_BUILD=1 npx vitest run). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/build/fonts-present.test.ts | 107 +++++++++++++++++++++ tests/build/icons-present.test.ts | 116 ++++++++++++++++++++++ tests/build/no-remote-fonts.test.ts | 144 ++++++++++++++++++++++++++++ tests/build/tokens-adopted.test.ts | 77 +++++++++++++++ tests/i18n/locale-parity.test.ts | 78 +++++++++++++++ tests/i18n/manifest-i18n.test.ts | 87 +++++++++++++++++ 6 files changed, 609 insertions(+) create mode 100644 tests/build/fonts-present.test.ts create mode 100644 tests/build/icons-present.test.ts create mode 100644 tests/build/no-remote-fonts.test.ts create mode 100644 tests/build/tokens-adopted.test.ts create mode 100644 tests/i18n/locale-parity.test.ts create mode 100644 tests/i18n/manifest-i18n.test.ts diff --git a/tests/build/fonts-present.test.ts b/tests/build/fonts-present.test.ts new file mode 100644 index 0000000..76735cd --- /dev/null +++ b/tests/build/fonts-present.test.ts @@ -0,0 +1,107 @@ +// tests/build/fonts-present.test.ts — Plan 01-12 Wave 0 RED unit test. +// +// Asserts that the self-hosted WOFF2 font bundle lives at src/shared/fonts/ +// per D-05 typography pairing + R2 Lora substitution (designer reply +// 2026-05-19; Newsreader → Lora for Cyrillic coverage). +// +// Polarity at Wave 0 land: RED across the board (src/shared/fonts/ does +// not exist). Flips GREEN after Wave 1 Task 1 lands the WOFF2 bundle. +// +// NOTE on Lora italic: per RESEARCH §A5, if upstream Lora variable file +// ships italic via the `ital` axis (single multi-axis file), the +// `Lora-Italic-VariableFont.woff2` file is omitted and the @font-face +// rule in tokens.css uses `font-style: oblique 0deg 14deg`. This test +// permits both layouts via the OPTIONAL_FILES set. + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; + +const FONTS_DIR = resolvePath(process.cwd(), 'src/shared/fonts'); +const TOKENS_CSS_PATH = resolvePath(process.cwd(), 'src/shared/tokens.css'); + +/** Required WOFF2 faces — Plex Sans + Plex Mono fixed weights + Lora normal */ +const REQUIRED_FONT_FILES: ReadonlyArray = [ + 'Lora-VariableFont.woff2', + 'IBMPlexSans-Regular.woff2', + 'IBMPlexSans-Medium.woff2', + 'IBMPlexSans-SemiBold.woff2', + 'IBMPlexSans-Bold.woff2', + 'IBMPlexMono-Regular.woff2', + 'IBMPlexMono-Medium.woff2', +]; + +/** Optional faces — A5 verify: present iff upstream ships separate italic file */ +const OPTIONAL_FONT_FILES: ReadonlyArray = [ + 'Lora-Italic-VariableFont.woff2', +]; + +/** Companion artifacts (LICENSE + README) */ +const REQUIRED_LICENSE_FILES: ReadonlyArray = [ + 'LICENSE-Lora.txt', + 'LICENSE-IBM-Plex.txt', + 'README.md', +]; + +/** WOFF2 magic bytes (RFC 8081): 'wOF2' */ +const WOFF2_SIGNATURE = Buffer.from([0x77, 0x4f, 0x46, 0x32]); + +describe('Plan 01-12: self-hosted OFL font bundle (Lora + Plex Sans + Plex Mono)', () => { + describe('required WOFF2 faces', () => { + for (const name of REQUIRED_FONT_FILES) { + it(`src/shared/fonts/${name} exists + non-empty + WOFF2 signature`, () => { + const filePath = resolvePath(FONTS_DIR, name); + expect(existsSync(filePath), `Expected ${filePath} (RED until Wave 1 Task 1)`).toBe(true); + const size = statSync(filePath).size; + expect(size, `${name} must be non-empty`).toBeGreaterThan(0); + const head = Buffer.alloc(4); + const fd = require('node:fs').openSync(filePath, 'r'); + try { + require('node:fs').readSync(fd, head, 0, 4, 0); + } finally { + require('node:fs').closeSync(fd); + } + expect( + head.equals(WOFF2_SIGNATURE), + `${name} signature must be 'wOF2' (0x77 0x4F 0x46 0x32); got ${head.toString('hex')}`, + ).toBe(true); + }); + } + }); + + it('tokens.css references every required WOFF2 file by filename', () => { + if (!existsSync(TOKENS_CSS_PATH)) { + // Will be RED until Wave 1 Task 2 lands tokens.css. Surface a clear diagnostic + // rather than letting downstream substring assertions fail with confusing output. + throw new Error(`src/shared/tokens.css missing — Wave 1 Task 2 lands this file`); + } + const css = readFileSync(TOKENS_CSS_PATH, 'utf8'); + for (const name of REQUIRED_FONT_FILES) { + expect( + css.includes(name), + `tokens.css must reference ${name} in a @font-face src url(); RED until Wave 1 Task 2 lands the @font-face block`, + ).toBe(true); + } + }); + + it('LICENSE-Lora.txt + LICENSE-IBM-Plex.txt + README.md exist (OFL attribution)', () => { + for (const name of REQUIRED_LICENSE_FILES) { + const filePath = resolvePath(FONTS_DIR, name); + expect(existsSync(filePath), `Expected ${filePath} (RED until Wave 1 Task 1)`).toBe(true); + expect(statSync(filePath).size, `${name} must be non-empty`).toBeGreaterThan(0); + } + }); + + it('optional Lora-Italic-VariableFont.woff2 — present iff upstream ships separate italic file', () => { + // A5 in RESEARCH Assumptions Log: variable Lora may consolidate italic + // via the `ital` axis. This test is informational — passes either way. + for (const name of OPTIONAL_FONT_FILES) { + const filePath = resolvePath(FONTS_DIR, name); + if (existsSync(filePath)) { + expect(statSync(filePath).size).toBeGreaterThan(0); + } + // If absent, the README.md should document the consolidation (verified + // empirically at execute time; not asserted here to avoid coupling). + } + }); +}); diff --git a/tests/build/icons-present.test.ts b/tests/build/icons-present.test.ts new file mode 100644 index 0000000..8df568c --- /dev/null +++ b/tests/build/icons-present.test.ts @@ -0,0 +1,116 @@ +// tests/build/icons-present.test.ts — Plan 01-12 Wave 0 RED unit test. +// +// Asserts that icons/icon{16,48,128}.png are rasterized from the Loom +// brand mark via rsvg-convert (Wave 2 Task 1) — replacing the Bug A +// placeholders (16-bit/color RGB) with 8-bit/color RGBA. +// +// Polarity at Wave 0 land: +// - Dimension + size FLOOR cases: GREEN today (placeholders are at +// correct dims + clear size floors). Stay GREEN after Wave 2. +// - Color-type RGBA case: RED today (placeholders are color-type 2 = +// RGB). Flips GREEN after Wave 2 (rsvg-convert default output is +// 8-bit/color RGBA, color-type 6). +// +// PNG header layout per ISO/IEC 15948 §11.2.2: +// - Bytes 0-7: signature 89 50 4E 47 0D 0A 1A 0A +// - Bytes 8-11: chunk length (big-endian uint32) +// - Bytes 12-15: chunk type 'IHDR' (49 48 44 52) +// - Bytes 16-19: width (big-endian uint32) +// - Bytes 20-23: height (big-endian uint32) +// - Byte 24: bit depth (8 or 16 for our cases) +// - Byte 25: color type — 2=RGB, 6=RGBA (load-bearing for RED→GREEN flip) +// +// References: +// - RESEARCH §3 (verified locally: rsvg-convert defaults to 8-bit RGBA) +// - assets-spec.md (Chrome imageUtil silent-rejection FLOORs) +// - Plan 01-13 harness A9 (icon-size floor at UAT-time) + +import { describe, expect, it } from 'vitest'; +import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const COLOR_TYPE_RGBA = 6; + +interface IconSpec { + readonly size: number; + readonly sizeFloorBytes: number; +} + +/** Per assets-spec.md Chrome imageUtil silent-rejection thresholds. */ +const ICON_SPECS: ReadonlyArray = [ + { size: 16, sizeFloorBytes: 200 }, + { size: 48, sizeFloorBytes: 500 }, + { size: 128, sizeFloorBytes: 1024 }, +]; + +/** + * Read the first N bytes of a file synchronously. Used to parse the PNG + * header without slurping the whole file. Returns a Buffer of length N + * (zero-padded if the file is shorter, which only happens on a corrupt + * non-PNG file we want to fail loudly on anyway). + */ +function readHead(filePath: string, n: number): Buffer { + const buf = Buffer.alloc(n); + const fd = openSync(filePath, 'r'); + try { + readSync(fd, buf, 0, n, 0); + } finally { + closeSync(fd); + } + return buf; +} + +/** Read big-endian uint32 at offset; ISO/IEC 15948 §7 mandates network byte order. */ +function readU32BE(buf: Buffer, offset: number): number { + return buf.readUInt32BE(offset); +} + +describe('Plan 01-12: icons rasterized from Loom mark (RGBA, 8-bit, clearing imageUtil FLOORs)', () => { + for (const spec of ICON_SPECS) { + const iconPath = resolvePath(process.cwd(), `icons/icon${spec.size}.png`); + + describe(`icons/icon${spec.size}.png (${spec.size}×${spec.size})`, () => { + it('exists at icons/iconN.png', () => { + expect(existsSync(iconPath), `Expected ${iconPath}`).toBe(true); + }); + + it(`size >= ${spec.sizeFloorBytes} bytes (Chrome imageUtil floor)`, () => { + const size = statSync(iconPath).size; + expect( + size, + `icon${spec.size}.png is ${size} B; need >= ${spec.sizeFloorBytes} B to clear Chrome imageUtil floor`, + ).toBeGreaterThanOrEqual(spec.sizeFloorBytes); + }); + + it('PNG signature (first 8 bytes match \\x89PNG\\r\\n\\x1a\\n)', () => { + const head = readHead(iconPath, 8); + expect( + head.equals(PNG_SIGNATURE), + `Expected PNG signature; got ${head.toString('hex')}`, + ).toBe(true); + }); + + it(`dimensions === ${spec.size}×${spec.size} (IHDR offset 16-23, big-endian uint32)`, () => { + const head = readHead(iconPath, 32); + const width = readU32BE(head, 16); + const height = readU32BE(head, 20); + expect(width, `icon${spec.size}.png width`).toBe(spec.size); + expect(height, `icon${spec.size}.png height`).toBe(spec.size); + }); + + it('color-type byte (IHDR offset 25) === 6 (RGBA — RED until Wave 2 overwrites placeholder)', () => { + const head = readHead(iconPath, 32); + const colorType = head[25]; + expect( + colorType, + colorType === COLOR_TYPE_RGBA + ? 'unreachable' + : `icon${spec.size}.png color-type=${colorType}; expected 6 (RGBA). ` + + `Placeholder PNGs are color-type 2 (RGB). RED until Wave 2 Task 1 ` + + `rasterizes via rsvg-convert (default output is 8-bit/color RGBA per RESEARCH §3).`, + ).toBe(COLOR_TYPE_RGBA); + }); + }); + } +}); diff --git a/tests/build/no-remote-fonts.test.ts b/tests/build/no-remote-fonts.test.ts new file mode 100644 index 0000000..997e98e --- /dev/null +++ b/tests/build/no-remote-fonts.test.ts @@ -0,0 +1,144 @@ +// tests/build/no-remote-fonts.test.ts — Plan 01-12 Wave 0 RED unit test. +// +// MV3 CSP enforcement gate: production dist/ MUST contain ZERO +// fonts.googleapis.com / https://fonts URLs. The canonical tokens.css +// (Wave 1 Task 2) replaces the design-incoming Google Fonts @import +// (line 12 of design-incoming/.../tokens.css) with local @font-face +// rules pointing at ./fonts/*.woff2. +// +// Polarity at Wave 0 land: +// - RED-vacuous today (the design-incoming tokens.css is not yet copied +// into src/shared/; once Wave 1 Task 2 copies + applies the surgical +// edits, this test confirms the edit removed the remote @import). +// +// Implementation mirrors tests/background/no-test-hooks-in-prod-bundle.test.ts: +// - Build via npm run build (gated by SKIP_BUILD=1 escape hatch) +// - Recursive walk over dist/ +// - For each file, count occurrences of forbidden substrings +// - Assert zero matches +// +// References: +// - RESEARCH §1 + §13 (MV3 CSP self-host enforcement) +// - Plan 01-12 threat model T-01-12-01 + T-01-12-02 (remote font load +// + CDN MITM mitigations) + +import { execFile } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { promisify } from 'node:util'; + +import { describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); + +/** Forbidden substrings — any occurrence in dist/ violates MV3 CSP self-host invariant. */ +const FORBIDDEN_REMOTE_STRINGS: ReadonlyArray = [ + 'fonts.googleapis.com', + 'https://fonts', + 'googleapis', +]; + +const PROD_BUILD_TIMEOUT_MS = 90_000; +const DIST_DIR = resolvePath(process.cwd(), 'dist'); + +interface ForbiddenMatch { + readonly filePath: string; + readonly count: number; +} + +function listAllFilesRecursive(root: string): ReadonlyArray { + const accumulator: string[] = []; + const stack: string[] = [root]; + while (stack.length > 0) { + const dir = stack.pop()!; + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = resolvePath(dir, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile()) { + accumulator.push(fullPath); + } + } + } + return accumulator.sort(); +} + +function countOccurrencesInFile(filePath: string, needle: string): number { + const binaryExtensions = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', + '.woff', '.woff2', '.ttf', '.otf', + ]); + const dotIdx = filePath.lastIndexOf('.'); + const ext = dotIdx >= 0 ? filePath.substring(dotIdx).toLowerCase() : ''; + if (binaryExtensions.has(ext)) return 0; + const stat = statSync(filePath); + if (stat.size === 0) return 0; + const text = readFileSync(filePath, 'utf8'); + let count = 0; + let from = 0; + for (;;) { + const idx = text.indexOf(needle, from); + if (idx < 0) break; + count += 1; + from = idx + needle.length; + } + return count; +} + +function findMatchesInDist(needle: string): ReadonlyArray { + const files = listAllFilesRecursive(DIST_DIR); + const matches: ForbiddenMatch[] = []; + for (const filePath of files) { + const count = countOccurrencesInFile(filePath, needle); + if (count > 0) { + matches.push({ filePath, count }); + } + } + return matches; +} + +async function runProductionBuild(): Promise { + await execFileAsync('npm', ['run', 'build'], { + timeout: PROD_BUILD_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + }); +} + +describe('production bundle has no remote font URLs (MV3 CSP — T-01-12-01)', () => { + it('npm run build completes and dist/ exists', { timeout: PROD_BUILD_TIMEOUT_MS + 5_000 }, async () => { + if (process.env.SKIP_BUILD !== '1') { + await runProductionBuild(); + } + expect( + existsSync(DIST_DIR), + `dist/ missing at ${DIST_DIR}. Either npm run build failed or SKIP_BUILD=1 was set ` + + `without a pre-existing build.`, + ).toBe(true); + }); + + for (const needle of FORBIDDEN_REMOTE_STRINGS) { + it(`dist/ does not contain '${needle}' (MV3 CSP self-host invariant)`, () => { + if (!existsSync(DIST_DIR)) { + throw new Error( + `dist/ missing — run \`npm run build\` first (SKIP_BUILD=1 set but no prior build).`, + ); + } + const matches = findMatchesInDist(needle); + expect( + matches.length, + matches.length === 0 + ? 'unreachable' + : `dist/ contains '${needle}' in ${matches.length} file(s) — violates MV3 CSP ` + + `style-src 'self' + font-src 'self'. Replace remote @import in tokens.css ` + + `with local @font-face rules pointing at ./fonts/*.woff2 (Wave 1 Task 2). ` + + `Offending files:\n` + + matches + .map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`) + .join('\n'), + ).toBe(0); + }); + } +}); diff --git a/tests/build/tokens-adopted.test.ts b/tests/build/tokens-adopted.test.ts new file mode 100644 index 0000000..b4dee5d --- /dev/null +++ b/tests/build/tokens-adopted.test.ts @@ -0,0 +1,77 @@ +// tests/build/tokens-adopted.test.ts — Plan 01-12 Wave 0 RED unit test. +// +// Asserts that the canonical token system (src/shared/tokens.css) exists +// and is adopted by src/popup/style.css with ZERO hex literals remaining. +// +// Polarity at Wave 0 land: +// (a) GREEN if src/shared/tokens.css already exists (will be RED until +// Wave 1 Task 2 lands the canonical copy). +// (b) RED until Wave 4 Task 1 migrates src/popup/style.css to @import +// tokens.css and replaces hex literals with var(--mks-*) references. +// (c) RED until Wave 4 Task 1 removes every #[0-9a-fA-F]{3,8} match +// from src/popup/style.css (loom-palette adoption via tokens). +// +// References: +// - Plan 01-12 §interfaces (.mks-word definition, R2 Lora substitution) +// - RESEARCH §1 + §9 (per-surface mini-tokens anti-pattern; collapse +// into canonical src/shared/tokens.css) + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; + +const TOKENS_CSS_PATH = resolvePath(process.cwd(), 'src/shared/tokens.css'); +const POPUP_STYLE_PATH = resolvePath(process.cwd(), 'src/popup/style.css'); +const WELCOME_STYLE_PATH = resolvePath(process.cwd(), 'src/welcome/welcome.css'); +const HEX_LITERAL_REGEX = /#[0-9a-fA-F]{3,8}\b/g; +const TOKENS_IMPORT_REGEX = /@import\s+["']\.\.\/shared\/tokens\.css["']/; + +describe('Plan 01-12: tokens.css adoption (canonical token system)', () => { + it('(a) src/shared/tokens.css exists + parses as readable CSS', () => { + expect( + existsSync(TOKENS_CSS_PATH), + `Expected src/shared/tokens.css at ${TOKENS_CSS_PATH} (RED until Wave 1 Task 2)`, + ).toBe(true); + const css = readFileSync(TOKENS_CSS_PATH, 'utf8'); + expect(css.length, 'tokens.css must be non-empty').toBeGreaterThan(100); + expect(css, 'tokens.css must declare :root with --mks-* tokens').toMatch(/:root\s*\{/); + expect(css, 'tokens.css must include --mks-font-display token').toMatch(/--mks-font-display\s*:/); + }); + + it('(b) src/popup/style.css imports ../shared/tokens.css', () => { + expect(existsSync(POPUP_STYLE_PATH), 'src/popup/style.css must exist').toBe(true); + const css = readFileSync(POPUP_STYLE_PATH, 'utf8'); + expect( + TOKENS_IMPORT_REGEX.test(css), + `Expected @import '../shared/tokens.css' in src/popup/style.css (RED until Wave 4 Task 1)`, + ).toBe(true); + }); + + it('(c) src/popup/style.css has ZERO hex color literals (loom palette via var(--mks-*))', () => { + const css = readFileSync(POPUP_STYLE_PATH, 'utf8'); + const matches = css.match(HEX_LITERAL_REGEX) ?? []; + expect( + matches.length, + matches.length === 0 + ? 'unreachable' + : `Expected 0 hex literals in src/popup/style.css; got ${matches.length}: ${matches.join(', ')}\n` + + `Replace each with var(--mks-*) per D-04 loom palette (Wave 4 Task 1).`, + ).toBe(0); + }); + + it('(d) src/welcome/welcome.css — if present, has ZERO hex literals (Plan 01-10 conditional)', () => { + // Conditional: only enforce if Plan 01-10 has landed src/welcome/welcome.css. + if (!existsSync(WELCOME_STYLE_PATH)) { + // SKIP without failing; documented in plan as conditional artifact. + return; + } + const css = readFileSync(WELCOME_STYLE_PATH, 'utf8'); + const matches = css.match(HEX_LITERAL_REGEX) ?? []; + expect( + matches.length, + matches.length === 0 + ? 'unreachable' + : `Expected 0 hex literals in src/welcome/welcome.css; got ${matches.length}: ${matches.join(', ')}`, + ).toBe(0); + }); +}); diff --git a/tests/i18n/locale-parity.test.ts b/tests/i18n/locale-parity.test.ts new file mode 100644 index 0000000..24bf4b3 --- /dev/null +++ b/tests/i18n/locale-parity.test.ts @@ -0,0 +1,78 @@ +// tests/i18n/locale-parity.test.ts — Plan 01-12 Wave 0 RED unit test. +// +// Enforces default_locale parity: every key in _locales/ru/messages.json +// must exist in _locales/en/messages.json (the default_locale fallback). +// Symmetric check for cleanliness; non-empty message strings throughout. +// +// Polarity at Wave 0 land: RED across the board (no _locales/ yet). +// Flips GREEN after Wave 3 Task 1 lands both locale files with parity. +// +// References: +// - RESEARCH Pitfall 4 (missing default_locale key returns empty string, +// not the key name — silent UI breakage if EN locale lacks a key the +// RU locale defines). + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; + +const EN_LOCALE_PATH = resolvePath(process.cwd(), '_locales/en/messages.json'); +const RU_LOCALE_PATH = resolvePath(process.cwd(), '_locales/ru/messages.json'); + +interface I18nEntry { + message: string; + description?: string; +} + +type LocaleFile = Record; + +function loadLocale(filePath: string): LocaleFile { + if (!existsSync(filePath)) { + throw new Error(`Missing locale file: ${filePath} (RED until Wave 3 Task 1)`); + } + return JSON.parse(readFileSync(filePath, 'utf8')); +} + +describe('Plan 01-12: _locales/ key parity (default_locale fallback invariant)', () => { + it('every key in _locales/ru/messages.json exists in _locales/en/messages.json', () => { + const ru = loadLocale(RU_LOCALE_PATH); + const en = loadLocale(EN_LOCALE_PATH); + const ruKeys = Object.keys(ru); + const missingInEn = ruKeys.filter((k) => !(k in en)); + expect( + missingInEn, + missingInEn.length === 0 + ? 'unreachable' + : `Keys present in ru/ but missing in en/ (silent UI breakage risk):\n - ${missingInEn.join('\n - ')}`, + ).toEqual([]); + }); + + it('every key in _locales/en/messages.json exists in _locales/ru/messages.json (symmetric)', () => { + const ru = loadLocale(RU_LOCALE_PATH); + const en = loadLocale(EN_LOCALE_PATH); + const enKeys = Object.keys(en); + const missingInRu = enKeys.filter((k) => !(k in ru)); + expect( + missingInRu, + missingInRu.length === 0 + ? 'unreachable' + : `Keys present in en/ but missing in ru/ (cleanliness):\n - ${missingInRu.join('\n - ')}`, + ).toEqual([]); + }); + + it('every key in _locales/en/messages.json has a non-empty .message string', () => { + const en = loadLocale(EN_LOCALE_PATH); + for (const k of Object.keys(en)) { + expect(typeof en[k].message, `en:${k}.message must be a string`).toBe('string'); + expect(en[k].message.length, `en:${k}.message must be non-empty`).toBeGreaterThan(0); + } + }); + + it('every key in _locales/ru/messages.json has a non-empty .message string', () => { + const ru = loadLocale(RU_LOCALE_PATH); + for (const k of Object.keys(ru)) { + expect(typeof ru[k].message, `ru:${k}.message must be a string`).toBe('string'); + expect(ru[k].message.length, `ru:${k}.message must be non-empty`).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/i18n/manifest-i18n.test.ts b/tests/i18n/manifest-i18n.test.ts new file mode 100644 index 0000000..9c6a165 --- /dev/null +++ b/tests/i18n/manifest-i18n.test.ts @@ -0,0 +1,87 @@ +// tests/i18n/manifest-i18n.test.ts — Plan 01-12 Wave 0 RED unit test. +// +// Asserts manifest.json migrated to chrome i18n placeholders with +// default_locale='en' + _locales/{en,ru}/messages.json carrying the +// D-07 + D-08 canonical strings. +// +// Polarity at Wave 0 land: RED across the board (manifest.name still +// 'AI Call Recorder'; no default_locale; no _locales/). Flips GREEN +// after Wave 3 Task 1 migrates manifest + lands messages.json files. +// +// References: +// - RESEARCH §10 + §11 (Chrome i18n schema; __MSG_* placeholder rules) +// - brand-decisions-v1.md D-07 override (`Mokosh — Session Capture`) +// - brand-decisions-v1.md D-08 tagline (`Thirty seconds ago, always at hand.`) + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; + +const MANIFEST_PATH = resolvePath(process.cwd(), 'manifest.json'); +const EN_LOCALE_PATH = resolvePath(process.cwd(), '_locales/en/messages.json'); +const RU_LOCALE_PATH = resolvePath(process.cwd(), '_locales/ru/messages.json'); + +interface I18nEntry { + message: string; + description?: string; +} + +type LocaleFile = Record; + +describe('Plan 01-12: manifest i18n migration (__MSG_*__ + default_locale)', () => { + it('manifest.json:name === "__MSG_extName__" (i18n placeholder)', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); + expect(manifest.name).toBe('__MSG_extName__'); + }); + + it('manifest.json:description === "__MSG_extDesc__"', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); + expect(manifest.description).toBe('__MSG_extDesc__'); + }); + + it('manifest.json:default_locale === "en"', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); + expect(manifest.default_locale).toBe('en'); + }); + + it('manifest.json:action.default_title === "__MSG_tooltipOff__"', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); + expect(manifest.action?.default_title).toBe('__MSG_tooltipOff__'); + }); +}); + +describe('Plan 01-12: _locales/en/messages.json (default_locale fallback)', () => { + it('_locales/en/messages.json exists + parses', () => { + expect(existsSync(EN_LOCALE_PATH), `Expected ${EN_LOCALE_PATH}`).toBe(true); + const en = JSON.parse(readFileSync(EN_LOCALE_PATH, 'utf8')); + expect(typeof en).toBe('object'); + }); + + it('en:extName === "Mokosh — Session Capture" (D-07 override)', () => { + const en: LocaleFile = JSON.parse(readFileSync(EN_LOCALE_PATH, 'utf8')); + expect(en.extName?.message).toBe('Mokosh — Session Capture'); + }); + + it('en:extDesc === "Thirty seconds ago, always at hand." (D-08 tagline)', () => { + const en: LocaleFile = JSON.parse(readFileSync(EN_LOCALE_PATH, 'utf8')); + expect(en.extDesc?.message).toBe('Thirty seconds ago, always at hand.'); + }); +}); + +describe('Plan 01-12: _locales/ru/messages.json (primary operator locale)', () => { + it('_locales/ru/messages.json exists + parses', () => { + expect(existsSync(RU_LOCALE_PATH), `Expected ${RU_LOCALE_PATH}`).toBe(true); + const ru = JSON.parse(readFileSync(RU_LOCALE_PATH, 'utf8')); + expect(typeof ru).toBe('object'); + }); + + it('ru:extName === "Mokosh — Запись сессии"', () => { + const ru: LocaleFile = JSON.parse(readFileSync(RU_LOCALE_PATH, 'utf8')); + expect(ru.extName?.message).toBe('Mokosh — Запись сессии'); + }); + + it('ru:extDesc === "Тридцать секунд назад, всегда под рукой."', () => { + const ru: LocaleFile = JSON.parse(readFileSync(RU_LOCALE_PATH, 'utf8')); + expect(ru.extDesc?.message).toBe('Тридцать секунд назад, всегда под рукой.'); + }); +});