| 1 | import assert from "node:assert/strict"; |
| 2 | import { execFileSync } from "node:child_process"; |
| 3 | import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | import os from "node:os"; |
| 5 | import path from "node:path"; |
| 6 | import test from "node:test"; |
| 7 | import { pathToFileURL } from "node:url"; |
| 8 | import { changedFiles, classifyPaths } from "./ci-paths.mjs"; |
| 9 | |
| 10 | test("explicit documentation skips build surfaces", () => { |
| 11 | for (const path of ["README.md", "docs/guide.md", "desktop/AGENTS.md", "desktop/README.md", "desktop/electron/README.md"]) { |
| 12 | const { flags, unknown } = classifyPaths([path]); |
| 13 | assert.equal(flags.desktop, false, path); |
| 14 | assert.equal(flags.memory, false, path); |
| 15 | assert.deepEqual(unknown, [], path); |
| 16 | } |
| 17 | }); |
| 18 | |
| 19 | test("frontend inputs select frontend, browser, memory and native validation", () => { |
| 20 | for (const path of ["desktop/frontend/src/components/Button.tsx", "desktop/frontend/public/help.md", "desktop/frontend/vite.config.ts", "desktop/pnpm-lock.yaml"]) { |
| 21 | const { flags } = classifyPaths([path]); |
| 22 | for (const name of ["desktop", "frontend", "browser", "memory", "native"]) assert.equal(flags[name], true, `${path}: ${name}`); |
| 23 | assert.equal(flags.memory_full, false, path); |
| 24 | } |
| 25 | }); |
| 26 | |
| 27 | test("App lifecycle and memory protocol inputs select the full memory screen", () => { |
| 28 | for (const path of [ |
| 29 | "desktop/frontend/src/App.tsx", |
| 30 | "desktop/frontend/src/AppRuntime.tsx", |
| 31 | "desktop/frontend/src/app-runtime/AppRuntime.tsx", |
| 32 | "desktop/frontend/src/app-shell/AppShell.tsx", |
| 33 | "desktop/frontend/src/components/Transcript.tsx", |
| 34 | "desktop/frontend/src/components/TranscriptCards.tsx", |
| 35 | "desktop/frontend/src/lib/useController.ts", |
| 36 | "desktop/frontend/src/lib/useControllerProfileCommands.ts", |
| 37 | "desktop/frontend/src/lib/subscriptionScope.ts", |
| 38 | "desktop/frontend/src/lib/useNavigationSurface.ts", |
| 39 | "desktop/frontend/src/lib/navigationSurfaceTransition.ts", |
| 40 | "desktop/frontend/src/lib/keyedResource.ts", |
| 41 | "desktop/frontend/src/lib/fileResource.ts", |
| 42 | "desktop/frontend/src/lib/useWorkspaceChangesResource.ts", |
| 43 | "desktop/frontend/src/lib/mcpServerLifecycle.ts", |
| 44 | "desktop/frontend/src/lib/fileNavigationLifetime.ts", |
| 45 | "desktop/frontend/src/lib/bridge.ts", |
| 46 | "desktop/frontend/src/lib/bridgeBenchFixtures.ts", |
| 47 | "desktop/frontend/src/lib/bridgeHistoryFixtures.ts", |
| 48 | "desktop/frontend/bench/app-memory.mjs", |
| 49 | "desktop/frontend/bench/app-browser.mjs", |
| 50 | "desktop/frontend/bench/app-page-actions.mjs", |
| 51 | ]) { |
| 52 | const { flags } = classifyPaths([path]); |
| 53 | assert.equal(flags.memory, true, path); |
| 54 | assert.equal(flags.memory_full, true, path); |
| 55 | } |
| 56 | }); |
| 57 | |
| 58 | test("Go, Electron and packaging inputs stay on their owning surfaces", () => { |
| 59 | const uninstaller = classifyPaths(["scripts/check-windows-uninstaller.mjs"]).flags; |
| 60 | assert.equal(uninstaller.packaging, true); |
| 61 | assert.equal(uninstaller.native, true); |
| 62 | assert.equal(uninstaller.memory, false); |
| 63 | let flags = classifyPaths(["internal/control/controller.go"]).flags; |
| 64 | assert.equal(flags.code, true); |
| 65 | assert.equal(flags.desktop_go, true); |
| 66 | assert.equal(flags.frontend, false); |
| 67 | assert.equal(flags.memory, false); |
| 68 | flags = classifyPaths(["desktop/electron/src/main/window.ts"]).flags; |
| 69 | assert.equal(flags.electron, true); |
| 70 | assert.equal(flags.frontend, false); |
| 71 | flags = classifyPaths(["desktop/packaging/package.mjs"]).flags; |
| 72 | assert.equal(flags.packaging, true); |
| 73 | assert.equal(flags.browser, false); |
| 74 | }); |
| 75 | |
| 76 | test("Windows shell ownership paths select the full builtin package", () => { |
| 77 | for (const path of ["internal/tool/builtin/workspace.go", "internal/tool/tool.go", "internal/sandbox/sandbox.go", "internal/permission/permission.go", "internal/permissionpreset/preset.go"]) { |
| 78 | assert.equal(classifyPaths([path]).flags.windows_builtin, true, path); |
| 79 | } |
| 80 | assert.equal(classifyPaths(["internal/acp/e2e_test.go"]).flags.windows_builtin, false); |
| 81 | }); |
| 82 | |
| 83 | test("mixed changes cannot hide affected work and unknown paths fail closed", () => { |
| 84 | let result = classifyPaths(["README.md", "desktop/frontend/src/App.tsx"]); |
| 85 | assert.equal(result.flags.memory, true); |
| 86 | result = classifyPaths(["shared/new-loader.js"]); |
| 87 | assert.deepEqual(result.unknown, ["shared/new-loader.js"]); |
| 88 | for (const name of ["code", "desktop", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging"]) |
| 89 | assert.equal(result.flags[name], true, name); |
| 90 | }); |
| 91 | |
| 92 | test("release notes and full events are deterministic", () => { |
| 93 | assert.equal(classifyPaths(["release-notes/v1.md"]).flags.notes_only, true); |
| 94 | const notesPush = classifyPaths(["release-notes/v1.md"], { full: true }).flags; |
| 95 | assert.equal(notesPush.notes_only, true); |
| 96 | assert.equal(notesPush.desktop, false); |
| 97 | assert.equal(classifyPaths([]).flags.desktop, false); |
| 98 | const full = classifyPaths([], { full: true }).flags; |
| 99 | assert.equal(full.notes_only, false); |
| 100 | for (const name of ["code", "desktop", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "windows_builtin"]) |
| 101 | assert.equal(full[name], true, name); |
| 102 | }); |
| 103 | |
| 104 | test("push and merge-base diffs retain deletions and renames", t => { |
| 105 | const root = mkdtempSync(path.join(os.tmpdir(), "reasonix-ci-paths-")); |
| 106 | t.after(() => rmSync(root, { recursive: true, force: true })); |
| 107 | mkdirSync(path.join(root, "desktop/frontend/src"), { recursive: true }); |
| 108 | writeFileSync(path.join(root, "desktop/frontend/src/old.ts"), "old"); |
| 109 | writeFileSync(path.join(root, "desktop/frontend/src/delete.ts"), "delete"); |
| 110 | execFileSync("git", ["init"], { cwd: root }); |
| 111 | execFileSync("git", ["add", "."], { cwd: root }); |
| 112 | execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "base"], { cwd: root }); |
| 113 | const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); |
| 114 | execFileSync("git", ["mv", "desktop/frontend/src/old.ts", "desktop/frontend/src/new.ts"], { cwd: root }); |
| 115 | execFileSync("git", ["rm", "desktop/frontend/src/delete.ts"], { cwd: root }); |
| 116 | execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-am", "change"], { cwd: root }); |
| 117 | const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); |
| 118 | const push = changedFiles({ base, head, mode: "push", cwd: root }); |
| 119 | const pull = changedFiles({ base, head, mode: "pull_request", cwd: root }); |
| 120 | for (const files of [push, pull]) { |
| 121 | assert.ok(files.includes("desktop/frontend/src/delete.ts")); |
| 122 | assert.ok(files.includes("desktop/frontend/src/new.ts")); |
| 123 | assert.equal(classifyPaths(files).flags.memory, true); |
| 124 | } |
| 125 | assert.deepEqual(changedFiles({ base: head, head, mode: "push", cwd: root }), []); |
| 126 | }); |
| 127 | |
| 128 | test("invalid diff identities fail instead of producing a skip", () => { |
| 129 | assert.throws(() => changedFiles({ base: "0".repeat(40), head: "HEAD" }), /non-zero/); |
| 130 | assert.throws(() => changedFiles({ base: "f".repeat(40), head: "HEAD" })); |
| 131 | }); |
| 132 | |
| 133 | test("CLI entry runs from paths with URL-significant characters", t => { |
| 134 | const root = mkdtempSync(path.join(os.tmpdir(), "reasonix-ci-entry-")); |
| 135 | t.after(() => rmSync(root, { recursive: true, force: true })); |
| 136 | const expectedNames = ["code", "desktop", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "windows_builtin", "release_control", "notes_only"]; |
| 137 | for (const directory of ["ordinary", "with space", "中文", "hash#directory", "literal%20directory"]) { |
| 138 | const target = path.join(root, directory, "ci-paths.mjs"); |
| 139 | mkdirSync(path.dirname(target), { recursive: true }); |
| 140 | copyFileSync(new URL("./ci-paths.mjs", import.meta.url), target); |
| 141 | const output = execFileSync(process.execPath, [target, "--full"], { encoding: "utf8" }); |
| 142 | const values = Object.fromEntries(output.trim().split("\n").map(line => line.split("="))); |
| 143 | assert.deepEqual(Object.keys(values).sort(), [...expectedNames].sort(), directory); |
| 144 | for (const name of expectedNames) assert.equal(values[name], name === "notes_only" ? "false" : "true", `${directory}: ${name}`); |
| 145 | } |
| 146 | }); |
| 147 | |
| 148 | test("CLI writes GitHub output and module import stays side-effect free", t => { |
| 149 | const root = mkdtempSync(path.join(os.tmpdir(), "reasonix-ci-output-")); |
| 150 | t.after(() => rmSync(root, { recursive: true, force: true })); |
| 151 | const target = path.join(root, "hash#literal%20", "ci-paths.mjs"); |
| 152 | const outputPath = path.join(root, "GitHub output.txt"); |
| 153 | mkdirSync(path.dirname(target), { recursive: true }); |
| 154 | copyFileSync(new URL("./ci-paths.mjs", import.meta.url), target); |
| 155 | const stdout = execFileSync(process.execPath, [target, "--full", "--github-output", outputPath], { encoding: "utf8" }); |
| 156 | assert.equal(stdout, ""); |
| 157 | const output = readFileSync(outputPath, "utf8"); |
| 158 | assert.equal(output.trim().split("\n").length, 15); |
| 159 | assert.match(output, /^desktop=true$/m); |
| 160 | assert.match(output, /^notes_only=false$/m); |
| 161 | |
| 162 | const imported = execFileSync(process.execPath, ["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(target).href)})`], { encoding: "utf8" }); |
| 163 | assert.equal(imported, ""); |
| 164 | |
| 165 | const stdinImported = execFileSync(process.execPath, ["--input-type=module", "-"], { |
| 166 | input: `await import(${JSON.stringify(pathToFileURL(target).href)});`, encoding: "utf8", |
| 167 | }); |
| 168 | assert.equal(stdinImported, ""); |
| 169 | }); |
| 170 | |
| 171 | // site and sdk are in this list because the required aggregates accept a |
| 172 | // skipped site or a no-op sdk; a PR that edits the routing contract would |
| 173 | // otherwise satisfy those expectations without running either surface. |
| 174 | test("CI routing changes exercise their owning workflow surfaces", () => { |
| 175 | for (const path of [".github/workflows/ci.yml", "scripts/ci-paths.mjs"]) { |
| 176 | const flags = classifyPaths([path]).flags; |
| 177 | for (const name of ["desktop", "desktop_go", "frontend", "browser", "electron", "native", "packaging", "site", "sdk"]) |
| 178 | assert.equal(flags[name], true, `${path}: ${name}`); |
| 179 | } |
| 180 | assert.equal(classifyPaths([".github/workflows/ci.yml"]).flags.memory, false); |
| 181 | for (const path of [".github/workflows/app-memory.yml", "scripts/ci-paths.mjs", "scripts/ci-paths.test.mjs"]) { |
| 182 | const flags = classifyPaths([path]).flags; |
| 183 | assert.equal(flags.memory, true, path); |
| 184 | assert.equal(flags.memory_full, true, path); |
| 185 | } |
| 186 | const memoryOnly = classifyPaths([".github/workflows/app-memory.yml"]).flags; |
| 187 | assert.equal(memoryOnly.packaging, false); |
| 188 | assert.equal(memoryOnly.sdk, false); |
| 189 | }); |
| 190 | |
| 191 | test("release control changes run focused contracts without selecting product suites", () => { |
| 192 | for (const path of [ |
| 193 | ".github/workflows/release-candidate.yml", |
| 194 | ".github/workflows/release-candidate-verify.yml", |
| 195 | ".github/workflows/release-promote.yml", |
| 196 | ".github/workflows/pages.yml", |
| 197 | "scripts/release-candidate.mjs", |
| 198 | "scripts/verify-release-artifact-archive.mjs", |
| 199 | "scripts/desktop-release-artifacts.test.mjs", |
| 200 | "npm/publish-candidate.mjs", |
| 201 | ]) { |
| 202 | const flags = classifyPaths([path]).flags; |
| 203 | assert.equal(flags.release_control, true, path); |
| 204 | assert.equal(flags.code, false, path); |
| 205 | assert.equal(flags.desktop, false, path); |
| 206 | } |
| 207 | }); |
| 208 |