| 1 | import assert from "node:assert/strict"; |
| 2 | import fs from "node:fs"; |
| 3 | import path from "node:path"; |
| 4 | import os from "node:os"; |
| 5 | import { spawnSync } from "node:child_process"; |
| 6 | import { fileURLToPath } from "node:url"; |
| 7 | |
| 8 | const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 9 | const read = (relativePath) => |
| 10 | fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); |
| 11 | |
| 12 | const frontendPackage = JSON.parse(read("desktop/frontend/package.json")); |
| 13 | const ciWorkflow = read(".github/workflows/ci.yml"); |
| 14 | const releaseWorkflow = read(".github/workflows/release-desktop.yml"); |
| 15 | const readme = read("README.md"); |
| 16 | const desktopReadme = read("desktop/README.md"); |
| 17 | const desktopBuildScript = read("scripts/desktop-build.sh"); |
| 18 | |
| 19 | // Execute the production shell wrapper to check the identity passed to packaging. |
| 20 | const packageShell = desktopBuildScript.match(/^package_shell\(\) \{\n[\s\S]*?^\}/m)?.[0]; |
| 21 | assert.ok(packageShell, "desktop builds must define package_shell"); |
| 22 | const sourceSha = "a".repeat(40); |
| 23 | const identityProbe = spawnSync("bash", ["-c", `${packageShell}\nnode() { printf '%s' "$REASONIX_COMMIT"; }\npackage_shell`], { |
| 24 | encoding: "utf8", |
| 25 | env: { ...process.env, ROOT: "/fixture", PLATFORM: "windows/amd64", VERSION: "v0.0.0-ci", CHANNEL: "canary", |
| 26 | SOURCE_SHA: sourceSha, GIT_COMMIT: sourceSha.slice(0, 12), BUILD_TIME_UTC: "2026-01-01T00:00:00Z" }, |
| 27 | }); |
| 28 | assert.ifError(identityProbe.error); |
| 29 | assert.equal(identityProbe.status, 0, identityProbe.stderr); |
| 30 | assert.equal(identityProbe.stdout.trim().split("\n").at(-1), sourceSha, "packaged identity must retain the full source SHA"); |
| 31 | |
| 32 | const jobBody = (workflow, jobName) => { |
| 33 | const lines = workflow.split("\n"); |
| 34 | const start = lines.findIndex((line) => line === ` ${jobName}:`); |
| 35 | assert.notEqual(start, -1, `workflow must define the ${jobName} job`); |
| 36 | const nextJob = lines |
| 37 | .slice(start + 1) |
| 38 | .findIndex((line) => /^ [a-zA-Z0-9_-]+:$/.test(line)); |
| 39 | const end = nextJob === -1 ? lines.length : start + 1 + nextJob; |
| 40 | return lines.slice(start, end).join("\n"); |
| 41 | }; |
| 42 | |
| 43 | const nodeVersions = (workflow) => |
| 44 | [...workflow.matchAll(/node-version:\s*["']?(\d+)/g)].map( |
| 45 | (match) => match[1], |
| 46 | ); |
| 47 | |
| 48 | assert.equal(read("desktop/frontend/.nvmrc").trim(), "24"); |
| 49 | assert.equal(frontendPackage.engines?.node, ">=24"); |
| 50 | assert.equal(frontendPackage.engines?.pnpm, ">=10 <11"); |
| 51 | assert.ok( |
| 52 | !fs.existsSync(path.join(repoRoot, "desktop/wails.json")), |
| 53 | "desktop/wails.json must be retired with the Wails shell", |
| 54 | ); |
| 55 | |
| 56 | for (const jobName of ["desktop-prepare", "desktop-go", "desktop-frontend", "desktop-browser-group", "desktop-macos", "desktop-windows"]) { |
| 57 | assert.deepEqual(nodeVersions(jobBody(ciWorkflow, jobName)), ["24"]); |
| 58 | } |
| 59 | |
| 60 | const releaseNodeVersions = nodeVersions(releaseWorkflow); |
| 61 | assert.ok(releaseNodeVersions.length > 0, "release workflow must set up Node"); |
| 62 | assert.deepEqual(new Set(releaseNodeVersions), new Set(["24"])); |
| 63 | |
| 64 | for (const [name, workflow] of [ |
| 65 | ["CI", ciWorkflow], |
| 66 | ["release", releaseWorkflow], |
| 67 | ]) { |
| 68 | const lines = workflow.split("\n"); |
| 69 | const pnpmVersions = lines.flatMap((line, index) => { |
| 70 | if (!line.includes("pnpm/action-setup@")) return []; |
| 71 | const block = lines.slice(index, index + 5).join("\n"); |
| 72 | return [block.match(/version:\s*(\d+)/)?.[1] ?? "missing"]; |
| 73 | }); |
| 74 | assert.ok(pnpmVersions.length > 0, `${name} workflow must set up pnpm`); |
| 75 | assert.deepEqual(new Set(pnpmVersions), new Set(["10"])); |
| 76 | } |
| 77 | |
| 78 | for (const [name, content] of [ |
| 79 | ["README.md", readme], |
| 80 | ["desktop/README.md", desktopReadme], |
| 81 | ]) { |
| 82 | assert.match(content, /npm i(?:nstall)? -g pnpm@10/); |
| 83 | assert.doesNotMatch( |
| 84 | content, |
| 85 | /wails/i, |
| 86 | `${name} must not reference the retired Wails toolchain`, |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | assert.match(readme, /#### CLI/); |
| 91 | assert.match(readme, /#### Desktop/); |
| 92 | |
| 93 | // The desktop build is the Electron packaging entrypoint: it must regenerate |
| 94 | // the shell/service contract and fail on drift before compiling anything. |
| 95 | assert.match( |
| 96 | desktopBuildScript, |
| 97 | /go run \. -emit-contract frontend\/src\/generated/, |
| 98 | "desktop builds must regenerate the host contract", |
| 99 | ); |
| 100 | // Verify the build guard's behavior, not its choice of git or diff syntax. |
| 101 | // A locally edited but current contract is valid; regeneration drift is not. |
| 102 | const guardStart = desktopBuildScript.indexOf('echo "==> desktop host contract drift check"'); |
| 103 | const guardEnd = desktopBuildScript.indexOf("# The packaging script", guardStart); |
| 104 | assert.ok(guardStart >= 0 && guardEnd > guardStart, "contract guard must precede packaging"); |
| 105 | const contractGuard = desktopBuildScript.slice(guardStart, guardEnd); |
| 106 | const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-contract-guard-")); |
| 107 | try { |
| 108 | for (const mode of ["current", "changed", "added", "removed", "generator-failed"]) { |
| 109 | const cwd = path.join(fixture, mode); |
| 110 | const generated = path.join(cwd, "frontend/src/generated"); |
| 111 | fs.mkdirSync(generated, { recursive: true }); |
| 112 | fs.writeFileSync(path.join(generated, "contract.json"), '{"version":10}\n'); |
| 113 | const script = `set -euo pipefail |
| 114 | go() { |
| 115 | case "$GUARD_TEST_MODE" in |
| 116 | current) : ;; |
| 117 | changed) echo '{"version":11}' > frontend/src/generated/contract.json ;; |
| 118 | added) echo '{}' > frontend/src/generated/new.json ;; |
| 119 | removed) rm frontend/src/generated/contract.json ;; |
| 120 | generator-failed) return 42 ;; |
| 121 | esac |
| 122 | } |
| 123 | ${contractGuard}`; |
| 124 | const result = spawnSync("bash", ["-c", script], { |
| 125 | cwd, encoding: "utf8", env: { ...process.env, GUARD_TEST_MODE: mode, TMPDIR: fixture }, |
| 126 | }); |
| 127 | assert.ifError(result.error); |
| 128 | if (mode === "current") { |
| 129 | assert.equal(result.status, 0, `current uncommitted contract rejected: ${result.stderr}`); |
| 130 | } else { |
| 131 | assert.notEqual(result.status, 0, `contract guard accepted ${mode}`); |
| 132 | } |
| 133 | } |
| 134 | } finally { |
| 135 | fs.rmSync(fixture, { recursive: true, force: true }); |
| 136 | } |
| 137 | // The release channel now rides in the Go service ldflags (the shell reads |
| 138 | // the same identity from resources/build.json written by package.mjs). |
| 139 | assert.match( |
| 140 | desktopBuildScript, |
| 141 | /service_ldflags="-X main\.version=\$VERSION -X main\.channel=\$CHANNEL/, |
| 142 | "desktop builds must link the release channel into the Go service", |
| 143 | ); |
| 144 | assert.match( |
| 145 | desktopBuildScript, |
| 146 | /\[ "\$os" = "windows" \] && service_ldflags="\$service_ldflags -H windowsgui"/, |
| 147 | "Windows desktop builds must link the Go service as a GUI-subsystem image", |
| 148 | ); |
| 149 | assert.match( |
| 150 | desktopBuildScript, |
| 151 | /GOOS="\$os" GOARCH="\$arch" go build -trimpath -ldflags="-s -w \$service_ldflags" -o "\$service_out"/, |
| 152 | "desktop service builds must consume the platform-specific linker flags", |
| 153 | ); |
| 154 | const windowsJob = jobBody(ciWorkflow, "desktop-windows"); |
| 155 | assert.match( |
| 156 | windowsJob, |
| 157 | /go build -trimpath -ldflags "-s -w -H windowsgui -X main\.version=v0\.0\.0-ci -X main\.channel=canary" -o build\/bin\/reasonix-desktop\.exe \./, |
| 158 | "Windows native startup CI must build the service as a GUI-subsystem image", |
| 159 | ); |
| 160 | assert.match( |
| 161 | windowsJob, |
| 162 | /node \.\.\/scripts\/verify-windows-gui-subsystem\.mjs build\/bin\/reasonix-desktop\.exe/, |
| 163 | "Windows native startup CI must verify the service PE subsystem", |
| 164 | ); |
| 165 | // The shell is packaged through the Electron packaging script, never wails build. |
| 166 | assert.match( |
| 167 | desktopBuildScript, |
| 168 | /node "\$ROOT\/desktop\/packaging\/package\.mjs" "\$PLATFORM" "\$VERSION" "\$CHANNEL"/, |
| 169 | "desktop builds must package the shell through desktop/packaging/package.mjs", |
| 170 | ); |
| 171 | assert.match( |
| 172 | desktopBuildScript, |
| 173 | /darwin\) report_bundle="\$ROOT\/desktop\/build\/candidate\/darwin-\$\{arch\}\/\$\{APPNAME\}\.app"/, |
| 174 | "macOS size reports must inspect the retained candidate instead of the deleted staging app", |
| 175 | ); |
| 176 | assert.doesNotMatch(desktopBuildScript, /wails build/); |
| 177 | assert.doesNotMatch( |
| 178 | desktopBuildScript, |
| 179 | /github\.com\/wailsapp\/wails\/v2\/cmd\/wails@/, |
| 180 | ); |
| 181 | // darwin/universal still ships one fat binary per Go artifact. |
| 182 | assert.match( |
| 183 | desktopBuildScript, |
| 184 | /lipo -create "\$service_tmp\/amd64" "\$service_tmp\/arm64" -output "\$service_out"/, |
| 185 | "darwin universal builds must lipo the desktop service", |
| 186 | ); |
| 187 | // Windows keeps one canonical SignPath payload: signing-files.txt enumerates |
| 188 | // every PE file, then package-windows-desktop.sh rebuilds from the payload. |
| 189 | assert.match( |
| 190 | desktopBuildScript, |
| 191 | /node "\$ROOT\/desktop\/packaging\/signing-files\.mjs" "\$payload_dir"/, |
| 192 | "windows builds must enumerate the signing payload", |
| 193 | ); |
| 194 | assert.match( |
| 195 | desktopBuildScript, |
| 196 | /VERSION="\$VERSION" "\$ROOT\/scripts\/package-windows-desktop\.sh" "\$arch" "\$payload_dir"/, |
| 197 | "windows builds must package from the signing payload", |
| 198 | ); |
| 199 | |
| 200 | console.log("desktop build contract: PASS"); |
| 201 |