返回 CodeWhale
public-surface-contract.test.ts
根目录 / web / lib / public-surface-contract.test.ts
1 import { createHash } from "node:crypto";
2 import {
3 chmodSync,
4 existsSync,
5 mkdtempSync,
6 readFileSync,
7 rmSync,
8 statSync,
9 writeFileSync,
10 } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { spawnSync } from "node:child_process";
14 import { describe, expect, it } from "vitest";
15 import { FACTS } from "./facts.generated";
16 import { SNIPPETS } from "./install-binary-snippets";
17 import { getChrome, getHome } from "./i18n/dictionaries";
18 import { footerProjectLinks } from "./i18n/links";
19
20 const root = new URL("../../", import.meta.url);
21
22 type PublicSurfaceMatrix = {
23 schemaVersion: number;
24 product: {
25 name: string;
26 description: string;
27 license: string;
28 terminology: Record<string, string>;
29 };
30 sourceCandidate: {
31 version: string;
32 providerCount: number;
33 toolCount: number;
34 sandboxBackends: string[];
35 };
36 latestPublishedRelease: {
37 tag: string;
38 version: string;
39 publishedAt: string;
40 url: string;
41 };
42 install: {
43 recommended: string;
44 binaries: string[];
45 channels: Record<string, string>;
46 androidTermux: {
47 status: string;
48 npm: string;
49 requiresMatchingPublishedAssets: boolean;
50 sourceBuild: boolean;
51 };
52 };
53 control: {
54 modes: string[];
55 permissionPostures: string[];
56 shortcuts: {
57 mode: { chord: string; when: string };
58 permissionPosture: { chord: string; when: string };
59 };
60 };
61 toolSurface: {
62 defaultActive: string[];
63 actions: Record<string, string[]>;
64 deferred: Record<string, string[]>;
65 compatibility: {
66 legacyAliases: string;
67 modelVisible: boolean;
68 toolSearchDiscoverable: boolean;
69 };
70 agentConcurrency: {
71 defaultConfigured: number;
72 maximumConfigured: number;
73 maximumAdmitted: number;
74 };
75 };
76 surfaces: { availableInSourceCandidate: string[] };
77 trust: Record<string, string>;
78 repository: {
79 canonical: string;
80 mirrors: string[];
81 creditSources: string[];
82 requiredCandidateCredits: string[];
83 };
84 screenshot: {
85 readme: string;
86 website: string;
87 sourceVersion: string | null;
88 sourceCommit: string | null;
89 terminal: string;
90 capture: string;
91 sources: string[];
92 };
93 [key: string]: unknown;
94 };
95
96 const matrix = JSON.parse(text("docs/public-surface-facts.json")) as PublicSurfaceMatrix;
97
98 function text(path: string): string {
99 return readFileSync(new URL(path, root), "utf8");
100 }
101
102 function bytes(path: string): Buffer {
103 return readFileSync(new URL(path, root));
104 }
105
106 function pngDimensions(image: Buffer): [number, number] {
107 expect(image.subarray(1, 4).toString("ascii")).toBe("PNG");
108 return [image.readUInt32BE(16), image.readUInt32BE(20)];
109 }
110
111 function comparableVersion(value: string): number {
112 const [major = 0, minor = 0, patch = 0] = value.split(".").map((n) => Number.parseInt(n, 10) || 0);
113 return major * 1_000_000 + minor * 1_000 + patch;
114 }
115
116 describe("public surface contracts", () => {
117 it("keeps source-candidate and published-release facts distinct and aligned", () => {
118 expect(matrix.schemaVersion).toBe(2);
119 expect(matrix.sourceCandidate.version).toBe(FACTS.version);
120 expect(matrix.sourceCandidate.providerCount).toBe(FACTS.providers.length);
121 expect(matrix.sourceCandidate.toolCount).toBe(FACTS.toolCount);
122 expect(matrix.sourceCandidate.sandboxBackends).toEqual(FACTS.sandboxBackends);
123 expect({
124 tag: matrix.latestPublishedRelease.tag,
125 version: matrix.latestPublishedRelease.version,
126 publishedAt: matrix.latestPublishedRelease.publishedAt,
127 url: matrix.latestPublishedRelease.url,
128 }).toEqual(FACTS.latestPublishedRelease);
129 // The published release may equal the source candidate: that is the normal
130 // state in the window between shipping vX.Y.Z and opening the next lane.
131 // What must never happen is the site advertising a version that is not
132 // published yet, so assert published <= source candidate rather than
133 // asserting they differ.
134 expect(comparableVersion(matrix.latestPublishedRelease.version)).toBeLessThanOrEqual(
135 comparableVersion(matrix.sourceCandidate.version),
136 );
137 expect(matrix.latestPublishedRelease).not.toHaveProperty("providerCount");
138 expect(matrix.latestPublishedRelease).not.toHaveProperty("toolCount");
139 expect(matrix.surfaces).not.toHaveProperty("stable");
140 expect(matrix.surfaces.availableInSourceCandidate).toContain("Web client");
141 // A capture may identify an exact build or explicitly remain an unversioned
142 // current session. Never infer release provenance from pixels alone.
143 const screenshotSourceVersion = matrix.screenshot.sourceVersion;
144 if (screenshotSourceVersion !== null) {
145 expect(comparableVersion(screenshotSourceVersion)).toBeLessThanOrEqual(
146 comparableVersion(matrix.sourceCandidate.version),
147 );
148 expect(matrix.screenshot.sourceCommit).toMatch(/^[0-9a-f]{40}$/);
149 } else {
150 expect(matrix.screenshot.sourceCommit).toBeNull();
151 expect(matrix.screenshot.capture).toContain("no published-release or exact-candidate claim");
152 }
153 });
154
155 it("backs product and install claims with package and documentation content", () => {
156 const readme = text("README.md");
157 const npmReadme = text("npm/codewhale/README.md");
158 const install = text("docs/INSTALL.md");
159 const changelog = text("CHANGELOG.md");
160 const license = text("LICENSE");
161 const npmArtifacts = text("npm/codewhale/scripts/artifacts.js");
162 const npmPackage = JSON.parse(text("npm/codewhale/package.json")) as {
163 description: string;
164 bin: Record<string, string>;
165 };
166
167 expect(matrix.product.name).toBe("Codewhale");
168 expect(matrix.product.license).toBe("MIT");
169 expect(matrix.product.description).toBe(npmPackage.description);
170 // The README intro was simplified; assert the hosted-and-local claim rather
171 // than the verbatim product tagline (kept in page-meta.ts / public-surface-facts.json).
172 expect(readme).toMatch(/hosted or local/);
173 expect(license).toContain("MIT License");
174 expect(matrix.install.recommended).toBe("npm install -g codewhale");
175 expect(readme).toContain(matrix.install.recommended);
176 expect(Object.keys(npmPackage.bin)).toEqual(matrix.install.binaries);
177 expect(matrix.install.channels).toEqual({
178 npm: "published releases only",
179 cargo: "published crates only",
180 prebuiltArchives: "published GitHub Releases only",
181 cnbMirror: "documented targets only",
182 });
183 expect(matrix.install.androidTermux).toEqual({
184 status: "preview",
185 npm: "preview",
186 requiresMatchingPublishedAssets: true,
187 sourceBuild: true,
188 });
189 // Pinned to FACTS.version, not a literal: these assertions used to carry
190 // the version number by hand, so every release bump broke them and the
191 // failure looked like a copy defect rather than a stale test.
192 expect(install).toContain(`v${FACTS.version} source candidate`);
193 expect(install).toContain("unpublished source candidate");
194 expect(install).toMatch(/Android \/ Termux \| arm64 \(aarch64\) \| ⚠️⁴ preview/);
195 expect(install).not.toContain(`wrapper is published at\nv${FACTS.version}`);
196 expect(npmReadme).toMatch(/^- Android arm64 \/ Termux \(preview;/m);
197 expect(npmReadme).toContain("requires matching Android assets");
198 expect(npmArtifacts).toContain("android: {");
199 for (const binary of [
200 "codewhale-android-arm64",
201 "codew-android-arm64",
202 "codewhale-tui-android-arm64",
203 ]) {
204 expect(npmArtifacts).toContain(binary);
205 }
206 // Matched by string prefix rather than by building a RegExp from
207 // FACTS.version: escaping only `.` left backslashes unescaped, which
208 // CodeQL flagged as incomplete escaping. The version needs no regex.
209 const heading = `## [${FACTS.version}] - `;
210 const headingLine = changelog
211 .split("\n")
212 .find((line) => line.startsWith(heading));
213 expect(headingLine, `missing "${heading}" changelog heading`).toBeTruthy();
214 const headingSuffix = headingLine?.slice(heading.length) ?? "";
215 expect(headingSuffix).toMatch(/^(?:Unreleased candidate|\d{4}-\d{2}-\d{2})$/);
216 if (headingSuffix === "Unreleased candidate") {
217 // Pre-tag candidate: notes still call it a source candidate, and the
218 // version compare link must not claim a tagged endpoint yet.
219 expect(changelog).toContain(`v${FACTS.version} source candidate`);
220 expect(changelog).not.toContain(`compare/v${FACTS.version}...HEAD`);
221 } else {
222 // Dated release: intro names the version, and the version's compare
223 // link points at a tag range (Unreleased may still use ...HEAD).
224 expect(changelog).toContain(`Codewhale v${FACTS.version}`);
225 const versionCompare = changelog
226 .split("\n")
227 .find((line) => line.startsWith(`[${FACTS.version}]: `));
228 expect(versionCompare, `missing [${FACTS.version}] compare link`).toBeTruthy();
229 expect(versionCompare).toContain(`...v${FACTS.version}`);
230 expect(versionCompare).not.toContain("...HEAD");
231 }
232 });
233
234 it("distinguishes two Cargo packages from the three installed commands", () => {
235 const installDoc = text("docs/INSTALL.md");
236 const installPage = text("web/app/[locale]/install/page.tsx");
237
238 expect(installDoc).toContain("Two Cargo packages are required");
239 expect(installDoc).toContain(
240 "`codewhale-cli` installs the `codewhale` and `codew` commands",
241 );
242 expect(installDoc).toContain(
243 "Download all three matching `codewhale`, `codew`, and `codewhale-tui`",
244 );
245 expect(installPage).toContain(
246 "# Install two Cargo packages; together they provide three commands",
247 );
248 expect(installPage).toContain("# codewhale + codew");
249 expect(installPage).toContain("The two Cargo packages install three commands");
250 expect(installPage).not.toContain("Install both binaries");
251 expect(installDoc).not.toContain("install both binaries from the release tag");
252 for (const platform of [
253 "macos-arm64",
254 "macos-x64",
255 "linux-arm64",
256 "linux-x64",
257 ] as const) {
258 expect(SNIPPETS[platform], platform).toContain(`codew-${platform}`);
259 expect(SNIPPETS[platform], platform).toContain(
260 `sudo mv codew-${platform} /usr/local/bin/codew`,
261 );
262 }
263 for (const arch of ["x64", "arm64"] as const) {
264 expect(SNIPPETS[`windows-${arch}`], arch).toContain(`codew-windows-${arch}.exe`);
265 expect(SNIPPETS[`windows-${arch}`], arch).toContain(
266 'Get-FileHash "$dest\\codew.exe"',
267 );
268 }
269 });
270
271 it("checks Unix release assets under their manifest filenames before renaming", () => {
272 const scratch = mkdtempSync(join(tmpdir(), "codewhale-install-checksum-"));
273 const mockBin = join(scratch, "bin");
274 const curlPath = join(mockBin, "curl");
275 const checksumPath = join(mockBin, "checksum");
276
277 try {
278 const mkdir = spawnSync("/bin/mkdir", ["-p", mockBin]);
279 expect(mkdir.status, mkdir.stderr.toString()).toBe(0);
280
281 writeFileSync(
282 curlPath,
283 `#!/bin/sh
284 output=""
285 url=""
286 while [ "$#" -gt 0 ]; do
287 case "$1" in
288 -o) shift; output="$1" ;;
289 http*) url="$1" ;;
290 esac
291 shift
292 done
293 [ -n "$output" ] || output=$(basename "$url")
294 if [ "$output" = codewhale-artifacts-sha256.txt ]; then
295 for platform in macos-arm64 macos-x64 linux-arm64 linux-x64; do
296 for binary in codewhale codew codewhale-tui; do
297 printf 'fixture-hash %s-%s\\n' "$binary" "$platform"
298 done
299 done > "$output"
300 else
301 printf 'fixture payload for %s\\n' "$output" > "$output"
302 fi
303 `,
304 );
305 writeFileSync(
306 checksumPath,
307 `#!/bin/sh
308 while [ "$#" -gt 0 ]; do shift; done
309 while read -r _hash filename; do
310 if [ ! -f "$filename" ]; then
311 echo "manifest target missing: $filename" >&2
312 exit 1
313 fi
314 done
315 `,
316 );
317 chmodSync(curlPath, 0o755);
318 chmodSync(checksumPath, 0o755);
319 for (const command of ["shasum", "sha256sum"]) {
320 const link = spawnSync("/bin/ln", ["-s", checksumPath, join(mockBin, command)]);
321 expect(link.status, link.stderr.toString()).toBe(0);
322 }
323
324 for (const platform of [
325 "macos-arm64",
326 "macos-x64",
327 "linux-arm64",
328 "linux-x64",
329 ] as const) {
330 const lines = SNIPPETS[platform].split("\n");
331 const checksumLine = lines.findIndex((line) => line.includes(" -c -"));
332 expect(checksumLine, platform).toBeGreaterThan(-1);
333 const result = spawnSync(
334 "/bin/bash",
335 ["-o", "pipefail", "-eu", "-c", lines.slice(0, checksumLine + 1).join("\n")],
336 {
337 cwd: scratch,
338 env: { ...process.env, PATH: `${mockBin}:${process.env.PATH ?? ""}` },
339 },
340 );
341 expect(result.status, `${platform}: ${result.stderr.toString()}`).toBe(0);
342 }
343 } finally {
344 rmSync(scratch, { recursive: true, force: true });
345 }
346 });
347
348 it("qualifies the resolved audit path and best-effort persistence", () => {
349 const installPage = text("web/app/[locale]/install/page.tsx");
350
351 expect(matrix.trust.audit).toContain("best-effort");
352 expect(matrix.trust.audit).toContain("$CODEWHALE_HOME");
353 expect(installPage).toContain(
354 "const CONFIG_TREE = `$CODEWHALE_HOME/ (default: ~/.codewhale/)",
355 );
356 expect(installPage).toContain(
357 "best-effort credential / approval / elevation events",
358 );
359 expect(installPage).toContain("尽力写入的凭证 / 审批 / 提权事件");
360 expect(installPage).not.toContain(
361 "audit.log credential / approval / elevation audit trail",
362 );
363 });
364
365 it("keeps modes, permission postures, and idle shortcuts exact", () => {
366 const modes = text("docs/MODES.md");
367 const keys = text("docs/KEYBINDINGS.md");
368 const readme = text("README.md");
369 const homepage = text("web/app/[locale]/page.tsx");
370 const docsMap = text("web/lib/docs-map.ts");
371 const matrixText = text("docs/public-surface-facts.json");
372
373 expect(matrix.control.modes).toEqual(["Plan", "Act", "Operate"]);
374 expect(matrix.control.permissionPostures).toEqual(["Ask", "Auto-Review", "Full Access"]);
375 // Tab is gated on the composer being EMPTY, not idle
376 // (crates/tui/src/tui/ui.rs:6978 `if !app.input.is_empty() { continue; }`
377 // immediately before `app.cycle_mode()`); Shift+Tab has no composer
378 // precondition at all (ui.rs:6363-6367 gates only on the modal stack).
379 expect(matrix.control.shortcuts).toEqual({
380 mode: { chord: "Tab", when: "composer empty" },
381 permissionPosture: {
382 chord: "Shift+Tab",
383 when: "always (suppressed only under a non-Config modal)",
384 },
385 });
386 for (const label of [...matrix.control.modes, ...matrix.control.permissionPostures]) {
387 expect(modes).toContain(label);
388 expect(homepage).toContain(label);
389 }
390 expect(modes).toContain("when the composer is empty");
391 expect(keys).toContain("When the composer is empty, cycle TUI mode");
392 expect(keys).toContain("`Shift+Tab`");
393 // The README must not re-teach the idle precondition in any language.
394 expect(keys).not.toContain("When the composer is idle");
395 expect(readme).toContain("when the composer is empty");
396 expect(readme).not.toContain("composer is idle");
397 expect(`${readme}\n${modes}\n${homepage}\n${docsMap}`).not.toContain("approval posture");
398 expect(matrixText).not.toContain('"approvalPostures"');
399
400 for (const path of [
401 "README.es-419.md",
402 "README.ja-JP.md",
403 "README.ko-KR.md",
404 "README.pt-BR.md",
405 "README.vi.md",
406 "README.zh-CN.md",
407 ]) {
408 expect(text(path), path).toContain("Shift+Tab");
409 }
410 });
411
412 it("enforces the nine-tool default-active policy and replay-only aliases", () => {
413 const toolDoc = text("docs/TOOL_SURFACE.md");
414 const design = text("docs/RUNTIME_SIMPLIFICATION_DESIGN.md").replace(/\s+/g, " ");
415 const toolsPage = text("web/app/[locale]/docs/tools/page.tsx");
416 const registry = text("crates/tui/src/tools/registry.rs");
417 const limits = text("crates/tui/src/config/subagent_limits.rs");
418 const roadmap = text("web/app/[locale]/roadmap/page.tsx");
419
420 expect(matrix.toolSurface.defaultActive).toEqual([
421 "Bash",
422 "File",
423 "Git",
424 "Run",
425 "agent",
426 "remember",
427 "tasks",
428 "work_update",
429 "tool_search",
430 ]);
431 expect(matrix.toolSurface.actions).toEqual({
432 Bash: ["run", "wait", "interact", "cancel"],
433 File: ["read", "list", "search_name", "search_content", "write", "edit", "patch"],
434 Git: ["status", "diff", "log", "show", "blame"],
435 Run: ["tests", "verifiers"],
436 });
437 expect(matrix.toolSurface.deferred).toEqual({ Web: ["search", "fetch", "wait"] });
438 expect(matrix.toolSurface.compatibility).toEqual({
439 legacyAliases: "replay-only",
440 modelVisible: false,
441 toolSearchDiscoverable: false,
442 });
443 expect(matrix.toolSurface.agentConcurrency).toEqual({
444 defaultConfigured: 64,
445 maximumConfigured: 128,
446 maximumAdmitted: 1024,
447 });
448 expect(limits).toContain("DEFAULT_MAX_SUBAGENTS: usize = 64");
449 expect(limits).toContain("MAX_SUBAGENTS: usize = 128");
450 expect(limits).toContain("MAX_SUBAGENT_ADMISSION: usize = 1024");
451 expect(roadmap).toContain("64 concurrent sessions by default, configurable to 128");
452 expect(roadmap.indexOf('{ title: "Local web client"')).toBeLessThan(
453 roadmap.indexOf('title: "Underway"'),
454 );
455 expect(roadmap).toContain("Implemented in the v0.9.1 source candidate");
456 // Nine, not ten: `update_plan` is a real tool but is not in
457 // DEFAULT_ACTIVE_NATIVE_TOOLS and appears nowhere in tool_catalog.rs.
458 // The facts file claimed it was default-active; the code never did.
459 expect(toolDoc).toContain("exactly these nine names");
460 for (const name of matrix.toolSurface.defaultActive) {
461 expect(toolDoc, name).toContain(`\`${name}\``);
462 expect(toolsPage, name).toContain(name);
463 }
464 expect(toolDoc).toContain("`Web` is a conditional, deferred action tool");
465 expect(toolDoc).toContain("hidden from the model");
466 expect(design).toContain(
467 "The final active names are `Bash`, `File`, `Git`, `Run`, `agent`, `remember`, `tasks`, `update_plan`, `work_update`, and `tool_search`.",
468 );
469 expect(registry).toContain('FileTool::new("File")');
470 expect(registry).toContain('GitTool::new("Git")');
471 expect(registry).toContain('RunTool::new("Run")');
472 expect(registry).toContain('BashTool::new("Bash")');
473 expect(registry).not.toContain('BashTool::new("exec_shell")');
474 expect(registry).not.toContain('FileTool::new("read_file")');
475 expect(registry).not.toContain('FileTool::new("list_dir")');
476 expect(toolsPage).not.toContain("read_file · list_dir");
477 expect(toolsPage).not.toContain("rlm_open · rlm_eval");
478 expect(toolsPage).not.toContain("docs/TOOL_LIFECYCLE.md");
479 });
480
481 it("states the hosted-provider privacy boundary without a false local-only promise", () => {
482 const faq = text("web/app/[locale]/faq/page.tsx");
483 const roadmap = text("web/app/[locale]/roadmap/page.tsx");
484 const providers = text("docs/PROVIDERS.md");
485 const runtime = text("docs/RUNTIME_API.md");
486
487 expect(matrix.trust.hostedProviderBoundary).toContain("selected hosted provider");
488 expect(matrix.trust.localInference).toContain("loopback local-model route");
489 // 0.9.4 ships an opt-in, default-off telemetry client that now has a live
490 // first-party endpoint as its shipped default. The claim has been rewritten
491 // twice as reality changed — "no Codewhale product telemetry", then "ships
492 // with no endpoint configured" — and this gate is what caught each drift,
493 // so the string moves to what is true now rather than being deleted.
494 //
495 // The consent half is asserted first and separately: an endpoint existing
496 // must never be allowed to soften "opt-in, off by default" into "we collect
497 // by default".
498 expect(matrix.trust.telemetry).toContain("opt-in, off by default");
499 expect(matrix.trust.telemetry).toContain(
500 "sends nothing until the first-run notice is answered with Enable",
501 );
502 // The destination is now named, and named exactly — a trust claim that says
503 // "an endpoint" without saying which one is not a trust claim.
504 expect(matrix.trust.telemetry).toContain(
505 "https://telemetry.codewhale.net/v1/telemetry",
506 );
507 expect(matrix.trust.telemetry).toContain("no IP, country, or geo column");
508 expect(matrix.trust.telemetry).toContain("three-month retention");
509 // The local dry-run path stays documented, because it is what lets a user
510 // audit the schema against their own traffic.
511 expect(matrix.trust.telemetry).toContain("local dry-run file");
512 expect(matrix.trust.telemetry).toContain("no mandatory hosted relay");
513 expect(faq).toContain("The hosted");
514 expect(faq).toContain("provider you select receives the prompt");
515 expect(faq).toContain("keep model inference local");
516 expect(faq).toContain("你选择的托管 provider 会收到");
517 expect(faq).not.toContain("No telemetry, no cloud processing of your code");
518 expect(faq).not.toContain("不会将你的代码上传到云端处理");
519 expect(roadmap).not.toContain("what happens there stays there");
520 expect(roadmap).not.toContain("你的数据不会离开");
521 expect(providers).toMatch(/Hosted\s+routes/);
522 expect(runtime).toContain("No hosted relay");
523 });
524
525 it("backs product vocabulary, contributor credit, and the exact MIT footer", () => {
526 const fleet = text("docs/FLEET.md");
527 const changelog = text("CHANGELOG.md");
528 const contributors = text("docs/CONTRIBUTORS.md");
529 const releaseCredits = text("web/lib/release-credits.ts");
530 const footer = text("web/components/footer.tsx");
531
532 expect(matrix.product.terminology).toEqual({
533 Fleet: "who does the work",
534 Workflow: "what order the work follows",
535 Lane: "one running Workflow instance",
536 Runtime: "where and how a Lane executes",
537 });
538 for (const [term, definition] of Object.entries(matrix.product.terminology)) {
539 expect(fleet).toContain(`**${term}** = ${definition}`);
540 }
541 expect(matrix.repository.requiredCandidateCredits).not.toHaveLength(0);
542 expect(matrix.repository.mirrors.some((mirror) => mirror.includes("gitee"))).toBe(false);
543 for (const handle of matrix.repository.requiredCandidateCredits) {
544 expect(changelog).toContain(handle);
545 expect(contributors).toContain(`github.com/${handle.slice(1)}`);
546 expect(releaseCredits).toContain(`"${handle}"`);
547 }
548 // The footer link sets are generated from the locale dictionaries
549 // (lib/i18n/links.ts) rather than hardcoded per-locale arrays, so the
550 // exact MIT pairing is asserted on the rendered contract for both the
551 // English and Chinese editions — same guarantee, one source.
552 expect(footerProjectLinks("en", getChrome("en")).at(-1)).toEqual({
553 label: "MIT license",
554 href: "https://github.com/Hmbown/CodeWhale/blob/main/LICENSE",
555 });
556 expect(footerProjectLinks("zh", getChrome("zh")).at(-1)).toEqual({
557 label: "MIT 许可证",
558 href: "https://github.com/Hmbown/CodeWhale/blob/main/LICENSE",
559 });
560 expect(footer).toContain("href={REPO_RELEASES_URL}");
561 expect(text("web/lib/i18n/links.ts")).toContain(
562 'export const REPO_RELEASES_URL = `${REPO_URL}/releases`',
563 );
564 expect(text("web/lib/i18n/links.ts")).toContain(
565 'export const REPO_URL = "https://github.com/Hmbown/CodeWhale"',
566 );
567 expect(footer).toContain("GITEE_ENABLED &&");
568 });
569
570 it("keeps the README and website on one optimized canonical product screenshot", () => {
571 const readmeImage = bytes(matrix.screenshot.readme);
572 const websiteImage = bytes(matrix.screenshot.website);
573 const digest = (image: Buffer) => createHash("sha256").update(image).digest("hex");
574
575 expect(digest(readmeImage)).toBe(digest(websiteImage));
576 expect(pngDimensions(readmeImage)).toEqual([1562, 1256]);
577 expect(statSync(new URL(matrix.screenshot.readme, root)).size).toBeLessThan(500_000);
578 expect(matrix.screenshot.terminal).toBe("unrecorded");
579
580 const readme = text("README.md");
581 const homepage = text("web/app/[locale]/page.tsx");
582 expect(readme).toContain("assets/screenshot.png");
583 expect(homepage).toContain('src="/codewhale-tui.png"');
584 // Alt text and figcaption are dictionary-backed (#4934); the screenshot
585 // contract now runs through the EN reference value and the page's use of
586 // it, and every routed locale must caption the same session honestly.
587 expect(homepage).toContain("alt={d.screenshotAlt}");
588 expect(homepage).toContain("<figcaption>{d.figcaption}</figcaption>");
589 expect(getHome("en").figcaption).toBe(
590 "Current Codewhale session · Operate mode · Ask permission posture",
591 );
592 expect(getHome("en").screenshotAlt).toContain("Operate mode");
593 for (const locale of ["zh", "ja", "vi", "ko", "ru", "uk", "es", "pt-BR", "id"]) {
594 const home = getHome(locale);
595 expect(home.figcaption, `${locale} figcaption`).toContain("Operate");
596 expect(home.figcaption, `${locale} figcaption`).toContain("Ask");
597 expect(home.screenshotAlt.trim().length, `${locale} alt`).toBeGreaterThan(0);
598 }
599 });
600
601 it("keeps the homepage wire strip a record of GitHub, not a summary of it", () => {
602 const ticker = text("web/components/ticker.tsx");
603 const homepage = text("web/app/[locale]/page.tsx");
604 const github = text("web/lib/github.ts");
605
606 // An empty or unreachable feed removes the strip. No skeleton, no
607 // placeholder row, no invented item.
608 expect(ticker).toContain("if (!ordered.length) return null;");
609 expect(homepage).toContain("{feed.length > 0 ? (");
610
611 // Drafts are the author's own not-ready marker, not an event.
612 expect(ticker).toContain("EVENT_STATES.includes(item.state)");
613 expect(ticker).not.toContain('"draft"');
614
615 // Every verb resolves through the caller's dictionary — the strip never
616 // hardcodes an English event word next to a translated page.
617 for (const key of [
618 "tickerMerged",
619 "tickerOpened",
620 "tickerClosed",
621 "tickerReleased",
622 "tickerFirstContribution",
623 "tickerBy",
624 "tickerAria",
625 ] as const) {
626 expect(homepage, `homepage passes chrome.${key}`).toContain(`chrome.${key}`);
627 for (const locale of ["en", "zh", "ja", "vi", "ko", "ru", "uk", "es", "pt-BR", "id"]) {
628 expect(getChrome(locale)[key].trim().length, `${locale} ${key}`).toBeGreaterThan(0);
629 }
630 }
631 expect(getChrome("en").tickerBy).toContain("{handle}");
632
633 // The first-contribution mark is GitHub's verdict, copied, never ours.
634 expect(github).toContain('association === "FIRST_TIME_CONTRIBUTOR"');
635 expect(ticker).toContain("item.firstTimeContributor");
636
637 // A verb is dated by its own event, so a merge is never dated by a later
638 // comment on the thread.
639 expect(github).toContain("eventAt");
640 expect(ticker).toContain("item.eventAt ?? item.updatedAt");
641
642 // Merged pull requests, issues, and releases — the whole life of the repo,
643 // within the existing three-call budget.
644 expect(github).toContain("/releases?per_page=");
645 expect(github).toContain('kind: "release"');
646 });
647
648 it("keeps reduced motion static without hiding the reasoning trace", () => {
649 const css = text("web/app/globals.css");
650 const terminalPlayer = text("web/components/terminal-player.tsx");
651
652 expect(css).toMatch(
653 /@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\.tp-caret\s*\{\s*animation:\s*none;\s*\}[\s\S]*?\.ticker-track\s*\{\s*animation:\s*none;\s*\}[\s\S]*?\}/,
654 );
655 // Freezing the track must not also hide the entries it stopped scrolling.
656 expect(css).toMatch(/\.ticker-viewport\s*\{\s*overflow-x:\s*auto;\s*\}/);
657 expect(terminalPlayer).toContain(
658 'window.matchMedia("(prefers-reduced-motion: reduce)").matches',
659 );
660 expect(terminalPlayer).toContain("setShown(Number.MAX_SAFE_INTEGER)");
661 expect(terminalPlayer).toContain("Server render shows the full trace");
662 });
663
664 it("keeps every fact-matrix source resolvable in the repository", () => {
665 const sources = new Set<string>();
666 const visit = (value: unknown) => {
667 if (Array.isArray(value)) {
668 value.forEach(visit);
669 } else if (value && typeof value === "object") {
670 for (const [key, child] of Object.entries(value)) {
671 if (key === "sources" || key === "creditSources") {
672 (child as string[]).forEach((source) => sources.add(source));
673 } else {
674 visit(child);
675 }
676 }
677 }
678 };
679 visit(matrix);
680
681 for (const source of sources) {
682 expect(existsSync(new URL(source, root)), source).toBe(true);
683 }
684 });
685 });
686
686 lines TYPESCRIPT