| 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 |