import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { assertBackendArtifactsFresh, isContainerRun, runGlobalSetup } from "./global-setup"; let backendDir: string; let binPath: string; const originalArgv = [...process.argv]; beforeEach(() => { backendDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-backend-fixture-")); fs.mkdirSync(path.join(backendDir, "bin"), { recursive: true }); fs.mkdirSync(path.join(backendDir, "internal", "task"), { recursive: true }); fs.writeFileSync(path.join(backendDir, "internal", "task", "service.go"), "package task\n"); fs.writeFileSync(path.join(backendDir, "go.mod"), "module kandev\n"); fs.utimesSync( path.join(backendDir, "go.mod"), new Date("2020-01-01T00:00:00Z"), new Date("2020-01-01T00:00:00Z"), ); binPath = path.join(backendDir, "bin", "kandev"); fs.writeFileSync(binPath, "binary"); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); process.argv = [...originalArgv]; fs.rmSync(backendDir, { recursive: true, force: true }); }); function touch(filePath: string, mtime: Date) { fs.utimesSync(filePath, mtime, mtime); } function assertBackendBinaryFresh( fixtureBackendDir: string, binPaths: string[], env: NodeJS.ProcessEnv, ): void { assertBackendArtifactsFresh( fixtureBackendDir, binPaths.map((artifactPath) => ({ path: artifactPath, rebuildTarget: "build" })), env, ); } function writeBuildIdentity(sourceRevision: string, artifacts: string[]): string { const identityPath = path.join(backendDir, "bin", "e2e-build-identity.json"); fs.writeFileSync( identityPath, JSON.stringify({ schema_version: 1, source_revision: sourceRevision, artifacts: Object.fromEntries( artifacts.map((artifactPath) => [ path.relative(backendDir, artifactPath).split(path.sep).join("/"), createHash("sha256").update(fs.readFileSync(artifactPath)).digest("hex"), ]), ), }), ); return identityPath; } describe("assertBackendBinaryFresh", () => { it("passes when the binary is newer than every backend source file", () => { touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2026-01-01T00:00:00Z"), ); touch(binPath, new Date("2026-01-02T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); it("throws with the remedy when a .go source file is newer than the binary", () => { touch(binPath, new Date("2026-01-01T00:00:00Z")); const staleSource = path.join(backendDir, "internal", "task", "service.go"); touch(staleSource, new Date("2026-01-02T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).toThrow( /Required E2E artifact is older than apps\/backend sources.*service\.go/s, ); }); it("still checks the local mock agent when KANDEV_E2E_BIN selects a custom backend", () => { touch(binPath, new Date("2026-01-01T00:00:00Z")); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2026-01-02T00:00:00Z"), ); expect(() => assertBackendBinaryFresh(backendDir, [binPath], { KANDEV_E2E_BIN: "/some/other/kandev" }), ).toThrow(/service\.go/); }); it("skips the check when KANDEV_E2E_SKIP_FRESHNESS=1", () => { touch(binPath, new Date("2026-01-01T00:00:00Z")); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2026-01-02T00:00:00Z"), ); expect(() => assertBackendBinaryFresh(backendDir, [binPath], { KANDEV_E2E_SKIP_FRESHNESS: "1" }), ).not.toThrow(); }); it("accepts stale artifact mtimes only after validating immutable build identity", () => { touch(binPath, new Date("2026-01-01T00:00:00Z")); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2026-01-02T00:00:00Z"), ); const identityPath = writeBuildIdentity("revision-1", [binPath]); expect(() => assertBackendBinaryFresh(backendDir, [binPath], { KANDEV_E2E_BUILD_IDENTITY: identityPath, KANDEV_E2E_EXPECTED_SOURCE_REVISION: "revision-1", }), ).not.toThrow(); }); it("rejects a build identity from another source revision", () => { const identityPath = writeBuildIdentity("revision-1", [binPath]); expect(() => assertBackendBinaryFresh(backendDir, [binPath], { KANDEV_E2E_BUILD_IDENTITY: identityPath, KANDEV_E2E_EXPECTED_SOURCE_REVISION: "revision-2", }), ).toThrow(/source revision.*revision-1.*revision-2/i); }); it("rejects an artifact whose bytes do not match its build identity", () => { const identityPath = writeBuildIdentity("revision-1", [binPath]); fs.writeFileSync(binPath, "tampered binary"); expect(() => assertBackendBinaryFresh(backendDir, [binPath], { KANDEV_E2E_BUILD_IDENTITY: identityPath, KANDEV_E2E_EXPECTED_SOURCE_REVISION: "revision-1", }), ).toThrow(/build identity checksum mismatch.*bin\/kandev/i); }); it("throws when an embedded non-Go asset is newer than the binary", () => { // profiles.yaml is //go:embed-ed, so a runtime feature-flag edit here // changes backend behaviour without touching a single .go file. touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-01-01T00:00:00Z"), ); touch(binPath, new Date("2026-01-01T00:00:00Z")); fs.mkdirSync(path.join(backendDir, "internal", "profiles"), { recursive: true }); const profiles = path.join(backendDir, "internal", "profiles", "profiles.yaml"); fs.writeFileSync(profiles, "e2e:\n"); touch(profiles, new Date("2026-01-02T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).toThrow(/profiles\.yaml/); }); it("ignores the synced web bundle under internal/webapp/embedded/generated", () => { // A copy of apps/web/dist that E2E never reads — fixtures/backend.ts serves // apps/web/dist directly — and `make sync-embedded-web` refreshes it without // relinking the binary, so counting it would demand a pointless rebuild. touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-01-01T00:00:00Z"), ); touch(binPath, new Date("2026-01-01T00:00:00Z")); const generated = path.join(backendDir, "internal", "webapp", "embedded", "generated"); fs.mkdirSync(generated, { recursive: true }); fs.writeFileSync(path.join(generated, "index.html"), ""); touch(path.join(generated, "index.html"), new Date("2026-06-01T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); it("ignores generated plugin fixture UI rebuilt after the backend binary", () => { touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-01-01T00:00:00Z"), ); touch(binPath, new Date("2026-01-01T00:00:00Z")); const generated = path.join(backendDir, "cmd", "plugin-fixture", "fixture-package", "ui"); fs.mkdirSync(generated, { recursive: true }); fs.writeFileSync(path.join(generated, "bundle.js"), "generated"); touch(path.join(generated, "bundle.js"), new Date("2026-06-01T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); it("ignores Go coverage and compiled-test artifacts left in the source tree", () => { // `make -C apps/backend test-coverage` writes these next to go.mod. They are // gitignored build outputs, so a test run must not demand a backend rebuild. touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-01-01T00:00:00Z"), ); touch(binPath, new Date("2026-01-01T00:00:00Z")); for (const name of ["coverage.out", "coverage.html", "task.test"]) { const artifact = path.join(backendDir, name); fs.writeFileSync(artifact, "generated"); touch(artifact, new Date("2026-06-01T00:00:00Z")); } expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); it("ignores nested Git metadata when checking backend source freshness", () => { touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-01-01T00:00:00Z"), ); touch(binPath, new Date("2026-01-01T00:00:00Z")); const gitInfo = path.join(backendDir, "internal", "task", ".git", "info"); fs.mkdirSync(gitInfo, { recursive: true }); const exclude = path.join(gitInfo, "exclude"); fs.writeFileSync(exclude, "# local repository metadata\n"); touch(exclude, new Date("2026-06-01T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); it("ignores Go test sources that are not linked into backend artifacts", () => { touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-01-01T00:00:00Z"), ); touch(binPath, new Date("2026-01-01T00:00:00Z")); const testSource = path.join(backendDir, "internal", "task", "service_test.go"); fs.writeFileSync(testSource, "package task\n"); touch(testSource, new Date("2026-06-01T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); it("still reports a stale binary when a coverage run sits alongside a real source edit", () => { // The property that makes the artifact exclusion safe: it removes those files // from consideration, it does not short-circuit the check. Widening the skip // patterns far enough to swallow real sources has to fail here. touch(binPath, new Date("2026-01-01T00:00:00Z")); const artifact = path.join(backendDir, "coverage.out"); fs.writeFileSync(artifact, "mode: set\n"); touch(artifact, new Date("2026-06-01T00:00:00Z")); const edited = path.join(backendDir, "internal", "task", "service.go"); touch(edited, new Date("2026-06-02T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).toThrow(/service\.go/); }); it("only skips exact artifact names, not files that merely resemble them", () => { touch(binPath, new Date("2026-01-01T00:00:00Z")); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-01-01T00:00:00Z"), ); const lookalike = path.join(backendDir, "internal", "task", "notes.out"); fs.writeFileSync(lookalike, "not a coverage profile\n"); touch(lookalike, new Date("2026-06-01T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).toThrow(/notes\.out/); }); it("checks every required binary when only the second binary is stale", () => { const mockAgentPath = path.join(backendDir, "bin", "mock-agent"); fs.writeFileSync(mockAgentPath, "binary"); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2026-01-02T00:00:00Z"), ); touch(binPath, new Date("2026-01-03T00:00:00Z")); touch(mockAgentPath, new Date("2026-01-01T00:00:00Z")); expect(() => assertBackendBinaryFresh(backendDir, [binPath, mockAgentPath], {})).toThrow( /mock-agent/, ); }); it("prints a rebuild command that works from the current directory", () => { touch(binPath, new Date("2026-01-01T00:00:00Z")); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2026-01-02T00:00:00Z"), ); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).toThrow( `make -C ${JSON.stringify(backendDir)} build`, ); }); it("uses the fixture package rebuild target when that package is stale", () => { const pluginPackage = path.join(backendDir, ".build", "kandev-plugin-e2e-1.0.0.tar.gz"); fs.mkdirSync(path.dirname(pluginPackage), { recursive: true }); fs.writeFileSync(pluginPackage, "package"); touch(pluginPackage, new Date("2026-01-01T00:00:00Z")); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2026-01-02T00:00:00Z"), ); expect(() => assertBackendArtifactsFresh( backendDir, [{ path: pluginPackage, rebuildTarget: "e2e-plugin-package" }], {}, ), ).toThrow(`make -C ${JSON.stringify(backendDir)} e2e-plugin-package`); }); it("ignores files under bin/, .build/, and testdata/ when finding the newest source", () => { touch(binPath, new Date("2026-01-01T00:00:00Z")); touch( path.join(backendDir, "internal", "task", "service.go"), new Date("2025-12-01T00:00:00Z"), ); fs.mkdirSync(path.join(backendDir, ".build"), { recursive: true }); fs.writeFileSync(path.join(backendDir, ".build", "artifact.go"), "package build\n"); touch(path.join(backendDir, ".build", "artifact.go"), new Date("2026-06-01T00:00:00Z")); fs.mkdirSync(path.join(backendDir, "internal", "task", "testdata"), { recursive: true }); fs.writeFileSync( path.join(backendDir, "internal", "task", "testdata", "fixture.go"), "package task\n", ); touch( path.join(backendDir, "internal", "task", "testdata", "fixture.go"), new Date("2026-06-01T00:00:00Z"), ); expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); }); describe("globalSetup", () => { it.each([ ["containers", ["node", "playwright", "test", "--project=containers"]], ["docker", ["node", "playwright", "test", "--project=docker"]], ["containers", ["node", "playwright", "test", "--project", "containers"]], ["docker", ["node", "playwright", "test", "--project", "docker"]], ])("recognizes the direct %s project selection", (_project, argv) => { expect(isContainerRun({}, argv)).toBe(true); }); it("checks the selected custom backend path instead of the default backend", () => { const customBackend = path.join(backendDir, "custom", "kandev"); vi.stubEnv("KANDEV_E2E_BIN", customBackend); vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1"); const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith(customBackend); expect(existsSync).not.toHaveBeenCalledWith(path.join(backendDirForTest(), "bin", "kandev")); }); it("reports a missing custom backend path", () => { const customBackend = path.join(backendDir, "missing", "kandev"); vi.stubEnv("KANDEV_E2E_BIN", customBackend); const existsSync = vi .spyOn(fs, "existsSync") .mockImplementation((artifactPath) => artifactPath !== customBackend); expect(() => runGlobalSetup({ verifyPluginFixtureIdentity: false })).toThrow( /KANDEV_E2E_BIN file does not exist/, ); expect(existsSync).toHaveBeenCalledWith(customBackend); }); // Regression: `globalSetup` used to derive "is this a container run" from // Playwright's `FullConfig.projects`, which always lists every project // declared in playwright.config.ts regardless of `--project=` selection — // so an ordinary `--project=chromium` run was indistinguishable from // `--project=containers` and wrongly demanded Linux-only helper binaries // that `make build` does not produce. See global-setup.ts's isContainerRun. it.each([ ["KANDEV_E2E_CONTAINERS", "1"], ["KANDEV_E2E_DOCKER", "1"], ])("checks Linux helper binaries for the %s marker", (marker, value) => { vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1"); vi.stubEnv(marker, value); const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), ); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "agentctl-linux-amd64"), ); }); it("checks Linux helper binaries for a direct containers project selection", () => { vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1"); vi.stubEnv("KANDEV_E2E_CONTAINERS", ""); vi.stubEnv("KANDEV_E2E_DOCKER", ""); process.argv = [...originalArgv, "--project=containers"]; const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), ); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "agentctl-linux-amd64"), ); }); it("checks Linux helper binaries for a direct Kubernetes compatibility selection", () => { vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1"); vi.stubEnv("KANDEV_E2E_CONTAINERS", ""); vi.stubEnv("KANDEV_E2E_DOCKER", ""); process.argv = [...originalArgv, "--project=kubernetes-compat"]; const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), ); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "agentctl-linux-amd64"), ); }); it("does not require Linux helper binaries for an ordinary run", () => { vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1"); vi.stubEnv("KANDEV_E2E_CONTAINERS", ""); vi.stubEnv("KANDEV_E2E_DOCKER", ""); const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).not.toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), ); expect(existsSync).not.toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "agentctl-linux-amd64"), ); }); }); function backendDirForTest(): string { return path.resolve(__dirname, "../../../apps/backend"); }