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:
Alassane
2026-08-19 04:08:06 -04:00
committed by GitHub
parent eab689c2a2
commit 957e6bbf7d
5 changed files with 494 additions and 18 deletions
+12
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+287
View File
@@ -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
View File
@@ -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.",
);
}