| 1 | import assert from "node:assert/strict"; |
| 2 | import { createHash } from "node:crypto"; |
| 3 | import { execFileSync } from "node:child_process"; |
| 4 | import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; |
| 5 | import os from "node:os"; |
| 6 | import path from "node:path"; |
| 7 | import test from "node:test"; |
| 8 | import { buildInputIdentity, createFrontendArtifact, verifyFrontendArtifact } from "./artifact-identity.mjs"; |
| 9 | |
| 10 | function fixture(t) { |
| 11 | const root = mkdtempSync(path.join(os.tmpdir(), "reasonix-frontend-artifact-")); |
| 12 | t.after(() => rmSync(root, { recursive: true, force: true })); |
| 13 | for (const dir of ["desktop/frontend/src", "desktop/frontend/dist"]) mkdirSync(path.join(root, dir), { recursive: true }); |
| 14 | for (const [name, value] of [["desktop/package.json", "{}"], ["desktop/pnpm-lock.yaml", "lock"], |
| 15 | ["desktop/frontend/package.json", "{}"], ["desktop/frontend/src/App.tsx", "export {}"], ["desktop/frontend/dist/index.html", "ok"]]) |
| 16 | writeFileSync(path.join(root, name), value); |
| 17 | execFileSync("git", ["init"], { cwd: root }); |
| 18 | execFileSync("git", ["add", "."], { cwd: root }); |
| 19 | execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "fixture"], { cwd: root }); |
| 20 | const sourceSHA = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); |
| 21 | const options = { root, dist: path.join(root, "desktop/frontend/dist"), manifest: path.join(root, "manifest.json"), |
| 22 | shell: "electron", channel: "stable", sourceSHA, runId: "12", attempt: "3", pnpmVersion: "10.0.0" }; |
| 23 | createFrontendArtifact(options); |
| 24 | return options; |
| 25 | } |
| 26 | |
| 27 | test("matching artifact verifies across producer platforms", t => { |
| 28 | const options = fixture(t); |
| 29 | const body = verifyFrontendArtifact(options); |
| 30 | body.toolchain.platform = body.toolchain.platform === "linux" ? "darwin" : "linux"; |
| 31 | body.toolchain.arch = "other"; |
| 32 | writeFileSync(options.manifest, JSON.stringify(body)); |
| 33 | assert.equal(verifyFrontendArtifact(options).sourceSHA, options.sourceSHA); |
| 34 | writeFileSync(path.join(options.root, "desktop/frontend/src/App.tsx"), "export {}\r\n"); |
| 35 | assert.equal(verifyFrontendArtifact(options).sourceSHA, options.sourceSHA); |
| 36 | }); |
| 37 | |
| 38 | test("batched blob reads preserve the version-one input digest", t => { |
| 39 | const options = fixture(t); |
| 40 | const identity = buildInputIdentity(options.root); |
| 41 | const hash = createHash("sha256"); |
| 42 | for (const name of identity.files) { |
| 43 | hash.update(name); |
| 44 | hash.update("\0"); |
| 45 | hash.update(execFileSync("git", ["-C", options.root, "show", `HEAD:${name}`])); |
| 46 | hash.update("\0"); |
| 47 | } |
| 48 | assert.equal(identity.sha256, hash.digest("hex")); |
| 49 | }); |
| 50 | |
| 51 | test("variant, workflow and toolchain identity mismatches fail", t => { |
| 52 | const options = fixture(t); |
| 53 | for (const changed of [{ shell: "browser" }, { channel: "canary" }, { runId: "13" }, { attempt: "4" }, { pnpmVersion: "10.1.0" }]) |
| 54 | assert.throws(() => verifyFrontendArtifact({ ...options, ...changed }), /mismatch/); |
| 55 | }); |
| 56 | |
| 57 | test("consumer retries verify the producer attempt while rebuilt producers advance it", t => { |
| 58 | const options = fixture(t); |
| 59 | const firstProducer = { ...options, attempt: "1" }; |
| 60 | createFrontendArtifact(firstProducer); |
| 61 | assert.equal(verifyFrontendArtifact(firstProducer).workflow.attempt, "1"); |
| 62 | // A consumer may be on run attempt 2 while its successful producer remains |
| 63 | // on attempt 1. Verification must use the producer output, not consumer state. |
| 64 | const consumerAttempt = "2"; |
| 65 | assert.notEqual(consumerAttempt, firstProducer.attempt); |
| 66 | assert.equal(verifyFrontendArtifact(firstProducer).workflow.attempt, "1"); |
| 67 | const rebuiltProducer = { ...options, attempt: "2" }; |
| 68 | createFrontendArtifact(rebuiltProducer); |
| 69 | assert.equal(verifyFrontendArtifact(rebuiltProducer).workflow.attempt, "2"); |
| 70 | assert.throws(() => verifyFrontendArtifact(firstProducer), /attempt mismatch/); |
| 71 | }); |
| 72 | |
| 73 | test("changed build input, dist, missing and old manifests fail", t => { |
| 74 | const options = fixture(t); |
| 75 | const body = verifyFrontendArtifact(options); |
| 76 | body.inputs.sha256 = "0".repeat(64); |
| 77 | writeFileSync(options.manifest, JSON.stringify(body)); |
| 78 | assert.throws(() => verifyFrontendArtifact(options), /build inputs/); |
| 79 | createFrontendArtifact(options); |
| 80 | writeFileSync(path.join(options.dist, "index.html"), "changed"); |
| 81 | assert.throws(() => verifyFrontendArtifact(options), /contents/); |
| 82 | writeFileSync(options.manifest, JSON.stringify({ schemaVersion: 0 })); |
| 83 | assert.throws(() => verifyFrontendArtifact(options), /schemaVersion mismatch/); |
| 84 | assert.throws(() => verifyFrontendArtifact({ ...options, manifest: path.join(options.root, "missing.json") }), /unavailable/); |
| 85 | }); |
| 86 |