test(01-12): wave-0 — scaffold RED unit tests (tokens / fonts / icons / no-remote-fonts / manifest-i18n / locale-parity)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 21:56:08 +02:00
parent 3fe018beb9
commit 34a9ce10d4
6 changed files with 609 additions and 0 deletions

View File

@@ -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<string> = [
'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<string> = [
'Lora-Italic-VariableFont.woff2',
];
/** Companion artifacts (LICENSE + README) */
const REQUIRED_LICENSE_FILES: ReadonlyArray<string> = [
'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).
}
});
});

View File

@@ -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<IconSpec> = [
{ 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);
});
});
}
});

View File

@@ -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<string> = [
'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<string> {
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<ForbiddenMatch> {
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<void> {
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);
});
}
});

View File

@@ -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);
});
});

View File

@@ -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<string, I18nEntry>;
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);
}
});
});

View File

@@ -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<string, I18nEntry>;
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('Тридцать секунд назад, всегда под рукой.');
});
});