test(e2e): guard against E2E runs on a stale backend binary (#2694)
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -392,10 +392,19 @@ jobs:
|
||||
# executor and the SSH executor's sshd target). Without --project the
|
||||
# container-bound specs would run here too, fail seeding (Docker not
|
||||
# enabled for these shards), and spam every shard.
|
||||
# global-setup.ts refuses to run a backend binary older than any
|
||||
# apps/backend source. That guard exists for local runs, where nothing
|
||||
# else notices `pnpm run build:e2e` rebuilding only the frontend. Here
|
||||
# the binary is staged out of band: the `build` job compiled it from
|
||||
# this same commit, and the shard's own checkout stamps every source
|
||||
# file with a *later* mtime than the artifact it downloads. Freshness is
|
||||
# already guaranteed by `needs: build`; the mtime comparison is not
|
||||
# meaningful across jobs.
|
||||
- name: Run planned E2E tests (shard ${{ matrix.shard }}/14)
|
||||
working-directory: apps/web
|
||||
env:
|
||||
E2E_SHARD: ${{ matrix.shard }}
|
||||
KANDEV_E2E_SKIP_FRESHNESS: "1"
|
||||
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/normal/${{ matrix.shard }}.json"
|
||||
|
||||
- name: Upload blob report
|
||||
@@ -573,11 +582,14 @@ jobs:
|
||||
name: e2e-shard-manifests
|
||||
path: e2e-manifests
|
||||
|
||||
# Same out-of-band staging as the chromium shards above — see the comment
|
||||
# on that step for why the backend-binary freshness guard is skipped.
|
||||
- name: Run planned container-backed E2E tests shard ${{ matrix.shard }}/6 (Docker executor + SSH executor)
|
||||
working-directory: apps/web
|
||||
env:
|
||||
KANDEV_E2E_CONTAINERS: "1"
|
||||
E2E_SHARD: ${{ matrix.shard }}
|
||||
KANDEV_E2E_SKIP_FRESHNESS: "1"
|
||||
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/containers/${{ matrix.shard }}.json"
|
||||
|
||||
# Named with the same `blob-report-*` prefix the merge job globs for
|
||||
|
||||
@@ -62,10 +62,10 @@ The acp-go-sdk has no `WithStartMeta`/`WithUpdateMeta`. To reproduce claude-agen
|
||||
|
||||
## Rebuild before running e2e (easy to miss)
|
||||
|
||||
The web e2e suite runs the **prebuilt host binary** `apps/backend/bin/mock-agent` (resolved via `PATH`); the `containers` project additionally uses `bin/mock-agent-linux-amd64`. `global-setup.ts` only checks the binary **exists**, not that it's current — so a stale binary silently runs the OLD behavior and your new scenario/edit won't take effect. After changing anything under `cmd/mock-agent`:
|
||||
The web e2e suite runs the **prebuilt host binary** `apps/backend/bin/mock-agent` (resolved via `PATH`). The `containers` project also uses `bin/mock-agent-linux-amd64`. `global-setup.ts` checks that each required artifact exists and is newer than the backend source tree. After changing anything under `cmd/mock-agent`:
|
||||
|
||||
```bash
|
||||
make -C apps/backend build-mock-agent # host binary (default suite)
|
||||
make -C apps/backend build # host backend and mock agent
|
||||
make -C apps/backend build-mock-agent-linux # only for the `containers` project
|
||||
```
|
||||
|
||||
|
||||
+31
-1
@@ -11,7 +11,37 @@ Playwright-based end-to-end tests. Each Playwright worker spawns its own real Go
|
||||
| `helpers/` | Reusable building blocks for specs (`api-client.ts`, `docker.ts`, `ssh.ts`, `git-helper.ts`, `ws-capture.ts`, …). |
|
||||
| `pages/` | Page Objects (one class per top-level UI surface — `SessionPage`, `KanbanPage`, `JiraSettingsPage`, `SSHSettingsPage`, …). |
|
||||
| `playwright.config.ts` | Project definitions, timeouts, sharding config. |
|
||||
| `global-setup.ts` | Pre-flight checks for required binaries (kandev, mock-agent) and the Vite web build. |
|
||||
| `global-setup.ts` | Pre-flight checks for required backend artifacts, the Vite web build, and backend artifact freshness. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
E2E runs the **prebuilt** backend, not a live rebuild — `fixtures/backend.ts` spawns
|
||||
`apps/backend/bin/kandev`, and `pnpm run build:e2e` only rebuilds the Vite bundle.
|
||||
Before running specs (or after touching anything under `apps/backend`), rebuild both:
|
||||
|
||||
```sh
|
||||
make -C apps/backend build # bin/kandev, bin/mock-agent
|
||||
make -C apps/backend e2e-plugin-package # .build/kandev-plugin-e2e-1.0.0.tar.gz, for tests/plugins/plugins.spec.ts
|
||||
cd apps/web && pnpm run build:e2e
|
||||
```
|
||||
|
||||
These are prerequisites, not optional steps — `global-setup.ts` fails fast with the
|
||||
exact remedy command if a required backend artifact is older than any file under
|
||||
`apps/backend` or is missing. This includes the fixture plugin package. The
|
||||
`containers` project also checks the Linux mock-agent and agentctl binaries.
|
||||
|
||||
"Any file", not just `*.go`: the binary `//go:embed`s a large asset surface, so
|
||||
editing `internal/profiles/profiles.yaml` (runtime feature-flag defaults), a
|
||||
`config/workflows/*.yml`, a `config/prompts/*.md`, or an `internal/i18n/locales/*.json`
|
||||
changes backend behaviour without touching a single `.go` file. Excluded are `bin/`,
|
||||
`.build/`, `testdata/`, the synced web bundle at `internal/webapp/embedded/generated/`,
|
||||
and the gitignored Go outputs that land in the source tree (`coverage.out`, `*.test`,
|
||||
`coverage.html`) so that `make -C apps/backend test-coverage` does not ask for a rebuild.
|
||||
|
||||
Skip all freshness checks with `KANDEV_E2E_SKIP_FRESHNESS=1` (both E2E CI jobs set
|
||||
this because they stage artifacts after their own checkout). `KANDEV_E2E_BIN`
|
||||
selects a custom backend binary. The setup still checks the local mock agent and
|
||||
other required artifacts.
|
||||
|
||||
## Playwright projects
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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 globalSetup, { assertBackendArtifactsFresh } from "./global-setup";
|
||||
|
||||
let backendDir: string;
|
||||
let binPath: string;
|
||||
|
||||
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();
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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("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 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("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("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);
|
||||
|
||||
(globalSetup as (config: unknown) => void)({ projects: [] });
|
||||
|
||||
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(() => (globalSetup as (config: unknown) => void)({ projects: [] })).toThrow(
|
||||
/KANDEV_E2E_BIN file does not exist/,
|
||||
);
|
||||
expect(existsSync).toHaveBeenCalledWith(customBackend);
|
||||
});
|
||||
|
||||
it("checks Linux helper binaries when the containers project is selected", () => {
|
||||
vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1");
|
||||
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
||||
|
||||
(globalSetup as (config: unknown) => void)({ projects: [{ name: "containers" }] });
|
||||
|
||||
expect(existsSync).toHaveBeenCalledWith(
|
||||
path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"),
|
||||
);
|
||||
expect(existsSync).toHaveBeenCalledWith(
|
||||
path.join(backendDirForTest(), "bin", "agentctl-linux-amd64"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function backendDirForTest(): string {
|
||||
return path.resolve(__dirname, "../../../apps/backend");
|
||||
}
|
||||
+162
-15
@@ -1,18 +1,49 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { FullConfig } from "@playwright/test";
|
||||
|
||||
const BACKEND_DIR = path.resolve(__dirname, "../../../apps/backend");
|
||||
const WEB_DIR = path.resolve(__dirname, "..");
|
||||
|
||||
export default function globalSetup() {
|
||||
const kandevBin = path.join(BACKEND_DIR, "bin", "kandev");
|
||||
const mockAgentBin = path.join(BACKEND_DIR, "bin", "mock-agent");
|
||||
export type BackendArtifact = {
|
||||
path: string;
|
||||
rebuildTarget: string;
|
||||
};
|
||||
|
||||
for (const bin of [kandevBin, mockAgentBin]) {
|
||||
if (!fs.existsSync(bin)) {
|
||||
throw new Error(`Required binary not found: ${bin}\nRun "make build-backend" first.`);
|
||||
}
|
||||
export default function globalSetup(config: FullConfig) {
|
||||
const customKandevBin = process.env.KANDEV_E2E_BIN;
|
||||
const kandevBin = customKandevBin ?? path.join(BACKEND_DIR, "bin", "kandev");
|
||||
const artifacts: BackendArtifact[] = [
|
||||
{ path: path.join(BACKEND_DIR, "bin", "mock-agent"), rebuildTarget: "build" },
|
||||
{
|
||||
path: path.join(BACKEND_DIR, ".build", "kandev-plugin-e2e-1.0.0.tar.gz"),
|
||||
rebuildTarget: "e2e-plugin-package",
|
||||
},
|
||||
];
|
||||
|
||||
if (!customKandevBin) {
|
||||
artifacts.unshift({ path: kandevBin, rebuildTarget: "build" });
|
||||
}
|
||||
if (isContainerRun(config, process.env)) {
|
||||
artifacts.push(
|
||||
{
|
||||
path: path.join(BACKEND_DIR, "bin", "mock-agent-linux-amd64"),
|
||||
rebuildTarget: "build-mock-agent-linux",
|
||||
},
|
||||
{
|
||||
path: path.join(BACKEND_DIR, "bin", "agentctl-linux-amd64"),
|
||||
rebuildTarget: "build-agentctl-linux",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const requiredArtifacts = customKandevBin
|
||||
? [{ path: kandevBin, rebuildTarget: undefined }, ...artifacts]
|
||||
: artifacts;
|
||||
for (const artifact of requiredArtifacts) {
|
||||
assertArtifactExists(artifact.path, artifact.rebuildTarget);
|
||||
}
|
||||
assertBackendArtifactsFresh(BACKEND_DIR, artifacts);
|
||||
|
||||
const spaIndex = path.join(WEB_DIR, "dist", "index.html");
|
||||
if (!fs.existsSync(spaIndex)) {
|
||||
@@ -20,17 +51,133 @@ export default function globalSetup() {
|
||||
}
|
||||
|
||||
assertPseudoCatalogBundled();
|
||||
}
|
||||
|
||||
// tests/plugins/plugins.spec.ts installs this package through the real
|
||||
// upload UI. Like the binaries above, this only checks existence — not
|
||||
// freshness — so rebuild after touching cmd/plugin-fixture (see
|
||||
// apps/backend/Makefile's e2e-plugin-package target).
|
||||
const pluginPackage = path.join(BACKEND_DIR, ".build", "kandev-plugin-e2e-1.0.0.tar.gz");
|
||||
if (!fs.existsSync(pluginPackage)) {
|
||||
function isContainerRun(config: FullConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
config.projects.some(({ name }) => name === "containers" || name === "docker") ||
|
||||
env.KANDEV_E2E_CONTAINERS === "1" ||
|
||||
env.KANDEV_E2E_DOCKER === "1"
|
||||
);
|
||||
}
|
||||
|
||||
function backendMakeCommand(backendDir: string, target: string): string {
|
||||
return `make -C ${JSON.stringify(backendDir)} ${target}`;
|
||||
}
|
||||
|
||||
function assertArtifactExists(artifactPath: string, rebuildTarget?: string): void {
|
||||
if (fs.existsSync(artifactPath)) return;
|
||||
|
||||
if (!rebuildTarget) {
|
||||
throw new Error(
|
||||
`E2E fixture plugin package not found: ${pluginPackage}\nRun "make -C apps/backend e2e-plugin-package" first.`,
|
||||
`The KANDEV_E2E_BIN file does not exist: ${artifactPath}\n` +
|
||||
"Set KANDEV_E2E_BIN to an existing backend executable.",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Required E2E artifact not found: ${artifactPath}\n` +
|
||||
`Run "${backendMakeCommand(BACKEND_DIR, rebuildTarget)}" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const BACKEND_SOURCE_SKIP_DIRS = new Set(["bin", ".build", "testdata"]);
|
||||
|
||||
/**
|
||||
* Go build and test *outputs* that land in the source tree rather than in
|
||||
* bin/ or .build/: `make -C apps/backend test-coverage` writes coverage.out
|
||||
* and coverage.html next to go.mod, and `go test -c` leaves `*.test` binaries
|
||||
* wherever it is run. The repo .gitignore lists exactly these three patterns;
|
||||
* none is `//go:embed`-ed, and no tracked file under apps/backend matches one.
|
||||
*
|
||||
* Without this, the ordinary `make test-coverage` → run E2E sequence aborts
|
||||
* demanding a backend rebuild because a coverage profile — which changes
|
||||
* nothing the binary contains — is newer than the binary.
|
||||
*/
|
||||
const BACKEND_SOURCE_SKIP_FILE_PATTERNS = [/^coverage\.out$/, /\.test$/, /^coverage\.html$/];
|
||||
|
||||
/**
|
||||
* `make sync-embedded-web` copies apps/web/dist here so the binary can serve
|
||||
* the SPA itself. E2E never reads that copy — fixtures/backend.ts points the
|
||||
* backend at apps/web/dist directly — and refreshing it does not require
|
||||
* relinking the binary (`make build-web && make sync-embedded-web` on its own
|
||||
* leaves the copy newer than bin/kandev), so counting it would demand a
|
||||
* pointless backend rebuild over a bundle the tests ignore.
|
||||
*/
|
||||
const EMBEDDED_WEB_DIR = path.join("internal", "webapp", "embedded", "generated");
|
||||
|
||||
/**
|
||||
* Every file counts, not just `*.go`: the binary `//go:embed`s a large asset
|
||||
* surface (`internal/profiles/profiles.yaml`, `config/workflows/*.yml`,
|
||||
* `config/prompts/*.md`, `internal/i18n/locales/*.json`,
|
||||
* `internal/office/configloader/{instructions,skills}/*`, …). Editing a
|
||||
* runtime feature-flag default in profiles.yaml changes backend behaviour
|
||||
* exactly as much as editing a `.go` file does, and an extension allowlist
|
||||
* cannot tell an embedded `*.md` under config/prompts from a stray AGENTS.md.
|
||||
*
|
||||
* The bias is deliberate: a spurious hit costs one `make build`, while a miss
|
||||
* costs the silent-stale-binary confusion this whole guard exists to prevent.
|
||||
*/
|
||||
function findNewestBackendSource(
|
||||
backendDir: string,
|
||||
): { file: string; mtimeMs: number } | undefined {
|
||||
let newest: { file: string; mtimeMs: number } | undefined;
|
||||
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (BACKEND_SOURCE_SKIP_DIRS.has(entry.name)) continue;
|
||||
if (path.relative(backendDir, entryPath) === EMBEDDED_WEB_DIR) continue;
|
||||
walk(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (BACKEND_SOURCE_SKIP_FILE_PATTERNS.some((pattern) => pattern.test(entry.name))) continue;
|
||||
|
||||
const mtimeMs = fs.statSync(entryPath).mtimeMs;
|
||||
if (!newest || mtimeMs > newest.mtimeMs) {
|
||||
newest = { file: entryPath, mtimeMs };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(backendDir);
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fixtures/backend.ts` spawns the *prebuilt* Go binary — `pnpm run build:e2e`
|
||||
* only rebuilds the Vite bundle, so nothing else checks the two agree. A stale
|
||||
* binary produces failures that look exactly like product regressions (missing
|
||||
* routes return 404, unknown JSON fields are silently dropped) instead of the
|
||||
* "rebuild the backend" message a developer actually needs.
|
||||
*
|
||||
* Compares binary mtime against the newest `apps/backend` source file rather
|
||||
* than the embedded git sha, since the sha still matches HEAD with uncommitted
|
||||
* backend edits — the more common case during local development.
|
||||
*/
|
||||
export function assertBackendArtifactsFresh(
|
||||
backendDir: string,
|
||||
artifacts: BackendArtifact[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
if (env.KANDEV_E2E_SKIP_FRESHNESS === "1") return;
|
||||
|
||||
const newestSource = findNewestBackendSource(backendDir);
|
||||
if (!newestSource) return;
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const artifactMtimeMs = fs.statSync(artifact.path).mtimeMs;
|
||||
if (artifactMtimeMs < newestSource.mtimeMs) {
|
||||
throw new Error(
|
||||
`Required E2E artifact is older than apps/backend sources: ${artifact.path}. ` +
|
||||
`${newestSource.file} is newer. Run "${backendMakeCommand(
|
||||
backendDir,
|
||||
artifact.rebuildTarget,
|
||||
)}". E2E uses prebuilt backend artifacts. The frontend build does not rebuild them.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +218,7 @@ function assertPseudoCatalogBundled() {
|
||||
if (markers.length === 0) {
|
||||
throw new Error(
|
||||
`KANDEV_I18N_COVERAGE=1 but no usable pseudo source catalog was found in ${catalogDir}.\n` +
|
||||
'The coverage oracle cannot mean anything without it — run "pnpm run i18n:pseudo" ' +
|
||||
'The coverage oracle cannot mean anything without it. Run "pnpm run i18n:pseudo" ' +
|
||||
"to regenerate it. See docs/i18n.md.",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user