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>
145 lines
4.9 KiB
TypeScript
145 lines
4.9 KiB
TypeScript
// 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);
|
|
});
|
|
}
|
|
});
|