diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index cb78d202e0..14849d805a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -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 diff --git a/apps/backend/cmd/mock-agent/AGENTS.md b/apps/backend/cmd/mock-agent/AGENTS.md index 2a5bc07f5f..837de20c8a 100644 --- a/apps/backend/cmd/mock-agent/AGENTS.md +++ b/apps/backend/cmd/mock-agent/AGENTS.md @@ -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 ``` diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 67eaea7070..5164738a90 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -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 diff --git a/apps/web/e2e/global-setup.test.ts b/apps/web/e2e/global-setup.test.ts new file mode 100644 index 0000000000..6d65e0ef00 --- /dev/null +++ b/apps/web/e2e/global-setup.test.ts @@ -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"), ""); + 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"); +} diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts index a07920c5f3..a7ad802b9d 100644 --- a/apps/web/e2e/global-setup.ts +++ b/apps/web/e2e/global-setup.ts @@ -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.", ); }