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:
107
tests/build/fonts-present.test.ts
Normal file
107
tests/build/fonts-present.test.ts
Normal 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).
|
||||
}
|
||||
});
|
||||
});
|
||||
116
tests/build/icons-present.test.ts
Normal file
116
tests/build/icons-present.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
144
tests/build/no-remote-fonts.test.ts
Normal file
144
tests/build/no-remote-fonts.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
77
tests/build/tokens-adopted.test.ts
Normal file
77
tests/build/tokens-adopted.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
78
tests/i18n/locale-parity.test.ts
Normal file
78
tests/i18n/locale-parity.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
87
tests/i18n/manifest-i18n.test.ts
Normal file
87
tests/i18n/manifest-i18n.test.ts
Normal 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('Тридцать секунд назад, всегда под рукой.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user