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