2b1d0cf7d1
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
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"), "<html></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");
|
|
}
|