518 lines
20 KiB
TypeScript
518 lines
20 KiB
TypeScript
import { test as base } from "@playwright/test";
|
||
import { type ChildProcess, spawn } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
|
||
const BACKEND_DIR = path.resolve(__dirname, "../../../../apps/backend");
|
||
const WEB_DIR = path.resolve(__dirname, "../..");
|
||
const KANDEV_BIN = path.join(BACKEND_DIR, "bin", "kandev");
|
||
const WEB_DIST_DIR = path.join(WEB_DIR, "dist");
|
||
// Auto-derive from PID if not explicitly set — prevents port clashes between concurrent test runs
|
||
// Modulo 30 keeps agentctl ports under 65535 (30001 + 30*1000 = 60001 max)
|
||
const rawPortOffset = process.env.E2E_PORT_OFFSET;
|
||
const E2E_PORT_OFFSET = rawPortOffset === undefined ? process.pid % 30 : Number(rawPortOffset);
|
||
if (!Number.isInteger(E2E_PORT_OFFSET) || E2E_PORT_OFFSET < 0 || E2E_PORT_OFFSET > 29) {
|
||
throw new Error(`E2E_PORT_OFFSET must be an integer 0-29, got: ${rawPortOffset}`);
|
||
}
|
||
const BACKEND_BASE_PORT = 18080 + E2E_PORT_OFFSET;
|
||
const HEALTH_TIMEOUT_MS = 30_000;
|
||
const HEALTH_POLL_MS = 250;
|
||
|
||
/**
|
||
* Returns true when the current run is the heavyweight container-backed
|
||
* Playwright project (Docker executor + SSH executor tests live here). The
|
||
* project was renamed `docker` → `containers` when SSH e2e tests joined it;
|
||
* the legacy name + env var are honored as deprecated aliases for one
|
||
* release. See apps/web/e2e/README.md.
|
||
*/
|
||
function isContainerProjectActive(projectName: string): boolean {
|
||
if (projectName === "containers" || projectName === "docker") return true;
|
||
if (process.env.KANDEV_E2E_CONTAINERS === "1") return true;
|
||
if (process.env.KANDEV_E2E_DOCKER === "1") return true;
|
||
return false;
|
||
}
|
||
|
||
export type BackendContext = {
|
||
port: number;
|
||
baseUrl: string;
|
||
frontendPort: number;
|
||
frontendUrl: string;
|
||
tmpDir: string;
|
||
/**
|
||
* Kill the backend process and respawn with the same config (DB, ports,
|
||
* tmpDir persist). The captured env is rebuilt from the baseline snapshot
|
||
* on every call, so `envOverrides` only apply to this restart — they do
|
||
* NOT leak into a subsequent restart. Call `restart()` with no args to
|
||
* revert to the baseline env. Only in-memory execution state (running
|
||
* agents, WS connections) is lost.
|
||
*/
|
||
restart: (envOverrides?: Record<string, string>) => Promise<void>;
|
||
};
|
||
|
||
function observeProcessExit(proc?: ChildProcess): {
|
||
state: { exited: boolean; exitCode: number | null; error?: Error };
|
||
dispose: () => void;
|
||
} {
|
||
const state: { exited: boolean; exitCode: number | null; error?: Error } = {
|
||
exited: proc?.exitCode !== null && proc?.exitCode !== undefined,
|
||
exitCode: proc?.exitCode ?? null,
|
||
};
|
||
const onExit = (code: number | null) => {
|
||
state.exited = true;
|
||
state.exitCode = code;
|
||
};
|
||
const onError = (error: Error) => {
|
||
state.error = error;
|
||
};
|
||
proc?.once("exit", onExit);
|
||
proc?.once("error", onError);
|
||
|
||
// Close the gap between inspecting exitCode and subscribing to the event.
|
||
// Node records exitCode before emitting `exit`, so this second read catches
|
||
// a child that exited between those two operations.
|
||
if (proc?.exitCode !== null && proc?.exitCode !== undefined) {
|
||
state.exited = true;
|
||
state.exitCode = proc.exitCode;
|
||
}
|
||
|
||
return {
|
||
state,
|
||
dispose: () => {
|
||
proc?.off("exit", onExit);
|
||
proc?.off("error", onError);
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function waitForHealth(
|
||
url: string,
|
||
timeoutMs: number,
|
||
proc?: ChildProcess,
|
||
): Promise<void> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
const processExit = observeProcessExit(proc);
|
||
|
||
try {
|
||
while (Date.now() < deadline) {
|
||
if (processExit.state.error) {
|
||
throw processExit.state.error;
|
||
}
|
||
if (processExit.state.exited) {
|
||
throw new Error(
|
||
`Backend process exited with code ${processExit.state.exitCode} while waiting for health at ${url}`,
|
||
);
|
||
}
|
||
try {
|
||
const res = await fetch(url);
|
||
if (res.ok) return;
|
||
} catch {
|
||
// not ready yet
|
||
}
|
||
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
|
||
}
|
||
throw new Error(`Service did not become healthy at ${url} within ${timeoutMs}ms`);
|
||
} finally {
|
||
processExit.dispose();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Polls until the given TCP port is no longer accepting connections, or until
|
||
* timeoutMs elapses. Used after killProcessGroup to avoid a fixed sleep: the
|
||
* OS may hold the port in TIME_WAIT for up to 60 s under heavy load, and
|
||
* sleeping a fixed 2 s races against that window.
|
||
*/
|
||
async function waitForPortFree(port: number, timeoutMs = 10_000): Promise<void> {
|
||
const { createConnection } = await import("node:net");
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
const free = await new Promise<boolean>((resolve) => {
|
||
const sock = createConnection({ port, host: "127.0.0.1" });
|
||
sock.once("connect", () => {
|
||
sock.destroy();
|
||
resolve(false); // port still occupied
|
||
});
|
||
sock.once("error", () => resolve(true)); // ECONNREFUSED → port is free
|
||
});
|
||
if (free) return;
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
}
|
||
// Timeout expired — proceed anyway; the new process will fail-fast if the
|
||
// port is still held and waitForHealth will surface the error.
|
||
}
|
||
|
||
/**
|
||
* Kills an entire process group. Used for the backend process which is spawned
|
||
* with `detached: true` so it becomes a process group leader. Sending signals
|
||
* to the negative PID targets all processes in that group (backend + agentctl).
|
||
* The 7s grace period gives agentctl time to cascade cleanup to agent process groups.
|
||
*/
|
||
function killProcessGroup(proc: ChildProcess): Promise<void> {
|
||
return new Promise<void>((resolve) => {
|
||
if (!proc.pid) {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const pid = proc.pid;
|
||
|
||
try {
|
||
process.kill(-pid, "SIGTERM");
|
||
} catch {
|
||
// Process group may already be gone
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const timeout = setTimeout(() => {
|
||
try {
|
||
process.kill(-pid, "SIGKILL");
|
||
} catch {
|
||
// Already dead
|
||
}
|
||
resolve();
|
||
}, 7_000);
|
||
|
||
proc.on("exit", () => {
|
||
clearTimeout(timeout);
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
type BackendFixtureLifecycle = {
|
||
stopProcess?: (proc: ChildProcess) => Promise<void>;
|
||
removeTempRoot?: (tmpDir: string) => void;
|
||
};
|
||
|
||
function removeOwnedTempRoot(tmpDir: string): void {
|
||
fs.rmSync(tmpDir, {
|
||
recursive: true,
|
||
force: true,
|
||
maxRetries: 3,
|
||
retryDelay: 100,
|
||
});
|
||
}
|
||
|
||
export async function runOwnedBackendFixture<T>(
|
||
tmpDir: string,
|
||
run: (registerProcess: (proc: ChildProcess) => void) => Promise<T>,
|
||
lifecycle: BackendFixtureLifecycle = {},
|
||
): Promise<T> {
|
||
const stopProcess = lifecycle.stopProcess ?? killProcessGroup;
|
||
const removeTempRoot = lifecycle.removeTempRoot ?? removeOwnedTempRoot;
|
||
let backendProc: ChildProcess | undefined;
|
||
let result: T | undefined;
|
||
const failures: unknown[] = [];
|
||
|
||
try {
|
||
result = await run((proc) => {
|
||
backendProc = proc;
|
||
});
|
||
} catch (error) {
|
||
failures.push(error);
|
||
}
|
||
|
||
if (backendProc) {
|
||
try {
|
||
await stopProcess(backendProc);
|
||
} catch (error) {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
|
||
try {
|
||
removeTempRoot(tmpDir);
|
||
} catch (error) {
|
||
failures.push(new Error(`Failed to remove E2E temporary root ${tmpDir}`, { cause: error }));
|
||
}
|
||
|
||
if (failures.length === 1) throw failures[0];
|
||
if (failures.length > 1) {
|
||
throw new AggregateError(failures, "Backend fixture failed and cleanup did not complete");
|
||
}
|
||
|
||
return result as T;
|
||
}
|
||
|
||
/**
|
||
* Spawn a backend process with the given environment. Returns the child process.
|
||
* The process is spawned with `detached: true` so it becomes a process group leader.
|
||
*/
|
||
function spawnBackendProcess(
|
||
env: Record<string, string>,
|
||
debug: boolean,
|
||
port: number,
|
||
): ChildProcess {
|
||
const proc = spawn(KANDEV_BIN, ["__backend"], {
|
||
env: env as unknown as NodeJS.ProcessEnv,
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
detached: true,
|
||
});
|
||
|
||
const logFile = debug ? fs.createWriteStream(`/tmp/e2e-backend-${port}.log`) : null;
|
||
proc.once("exit", () => {
|
||
logFile?.end();
|
||
});
|
||
proc.stderr?.on("data", (chunk: Buffer) => {
|
||
if (debug) {
|
||
process.stderr.write(`[backend:${port}] ${chunk.toString()}`);
|
||
logFile?.write(chunk);
|
||
}
|
||
});
|
||
proc.stdout?.on("data", (chunk: Buffer) => {
|
||
if (debug) {
|
||
process.stderr.write(`[backend-log:${port}] ${chunk.toString()}`);
|
||
logFile?.write(chunk);
|
||
}
|
||
});
|
||
|
||
return proc;
|
||
}
|
||
|
||
/**
|
||
* Worker-scoped fixture that spawns an isolated backend process and
|
||
* a Go-served SPA frontend. Each Playwright worker gets its own
|
||
* backend on a unique port with an isolated HOME, database, and data
|
||
* directory. Browser traffic hits that same backend, which serves the
|
||
* Vite assets and route-aware boot payload.
|
||
*/
|
||
export const backendFixture = base.extend<object, { backend: BackendContext }>({
|
||
backend: [
|
||
async ({ browserName: _browserName }, use, workerInfo) => {
|
||
const backendPort = BACKEND_BASE_PORT + workerInfo.workerIndex;
|
||
const frontendPort = backendPort;
|
||
const tmpDir = fs.mkdtempSync(
|
||
path.join(os.tmpdir(), `kandev-e2e-${workerInfo.workerIndex}-`),
|
||
);
|
||
let backendProc: ChildProcess | undefined;
|
||
|
||
await runOwnedBackendFixture(tmpDir, async (registerProcess) => {
|
||
const homeDir = path.join(tmpDir, ".kandev");
|
||
const dbPath = path.join(tmpDir, "kandev.db");
|
||
const worktreeBase = path.join(tmpDir, "worktrees");
|
||
const repoCloneBase = path.join(tmpDir, "repos");
|
||
|
||
fs.mkdirSync(homeDir, { recursive: true });
|
||
fs.mkdirSync(worktreeBase, { recursive: true });
|
||
fs.mkdirSync(repoCloneBase, { recursive: true });
|
||
|
||
// Write a minimal .gitconfig so git doesn't prompt for identity
|
||
// and disable signing to avoid SSH/GPG key lookups in the isolated HOME.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, ".gitconfig"),
|
||
"[user]\n name = E2E Test\n email = e2e@test.local\n[commit]\n gpgsign = false\n[tag]\n gpgsign = false\n",
|
||
);
|
||
|
||
// Give each worker its own agentctl port range, offset from the default
|
||
// range (41001-41100) to avoid conflicts with a running dev instance.
|
||
// The async cleanup of agent instances runs after each test deletes its
|
||
// tasks, so during a 60+ test shard the in-flight cleanup queue can hold
|
||
// several dozen ports at any given moment. 200 ports per worker keeps
|
||
// headroom for that without overflowing the 65535 port space. With the
|
||
// current playwright config (workers: 1, so workerIndex == 0) and shard
|
||
// offsets capped at 29, the highest port used is 30001 + 29*1000 + 199
|
||
// = 59200. The `workerIndex * 200` term is defensive for the case where
|
||
// a future config sets workers > 1.
|
||
const agentctlPortBase = 30001 + E2E_PORT_OFFSET * 1000 + workerInfo.workerIndex * 200;
|
||
const agentctlPortMax = agentctlPortBase + 199;
|
||
|
||
// Install a `git` shim that can sleep on `fetch`/`pull` before execing
|
||
// the real git binary. Tests that need to simulate slow network git
|
||
// operations write a millisecond value to `${tmpDir}/git-delay-ms`; the
|
||
// shim reads it on every invocation and sleeps the matching duration.
|
||
// When the file is absent the shim is a transparent passthrough, so
|
||
// other tests in the same worker are unaffected.
|
||
const shimDir = path.join(tmpDir, "bin");
|
||
const shimPath = path.join(shimDir, "git");
|
||
const shimDelayFile = path.join(tmpDir, "git-delay-ms");
|
||
const shimGitLabPushFile = path.join(tmpDir, "gitlab-push-remote");
|
||
const shimGitLabPushRecordFile = path.join(tmpDir, "gitlab-push-record");
|
||
const originalPath = process.env.PATH ?? "";
|
||
fs.mkdirSync(shimDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
shimPath,
|
||
`#!/bin/sh
|
||
# The worktree manager invokes Git with -c core.longpaths=true. Inspect the
|
||
# subcommand in a subshell so skipping its option/value pair does not change
|
||
# the original arguments passed through to the real Git binary below.
|
||
git_subcommand=$(
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
-c)
|
||
shift
|
||
[ "$#" -gt 0 ] && shift
|
||
;;
|
||
*)
|
||
printf '%s\\n' "$1"
|
||
exit 0
|
||
;;
|
||
esac
|
||
done
|
||
)
|
||
if [ -f "$KANDEV_E2E_GIT_DELAY_FILE" ] && { [ "$git_subcommand" = "fetch" ] || [ "$git_subcommand" = "pull" ]; }; then
|
||
delay_ms=$(cat "$KANDEV_E2E_GIT_DELAY_FILE" 2>/dev/null)
|
||
case "$delay_ms" in
|
||
''|*[!0-9]*) ;;
|
||
*)
|
||
if [ "$delay_ms" -gt 0 ]; then
|
||
sleep_secs=$((delay_ms / 1000))
|
||
[ "$sleep_secs" -lt 1 ] && sleep_secs=1
|
||
sleep "$sleep_secs"
|
||
fi
|
||
;;
|
||
esac
|
||
fi
|
||
if [ "$git_subcommand" = "push" ] && [ -f "$KANDEV_E2E_GITLAB_PUSH_FILE" ]; then
|
||
expected_remote=$(cat "$KANDEV_E2E_GITLAB_PUSH_FILE" 2>/dev/null)
|
||
actual_remote=$(PATH="$KANDEV_E2E_ORIGINAL_PATH" git config --get remote.origin.url 2>/dev/null)
|
||
if [ -n "$expected_remote" ] && [ "$actual_remote" = "$expected_remote" ]; then
|
||
printf '%s\n' "$*" > "$KANDEV_E2E_GITLAB_PUSH_RECORD_FILE"
|
||
exit 0
|
||
fi
|
||
fi
|
||
export PATH="$KANDEV_E2E_ORIGINAL_PATH"
|
||
exec git "$@"
|
||
`,
|
||
{ mode: 0o755 },
|
||
);
|
||
|
||
// Opt-in: Docker E2E project or KANDEV_E2E_DOCKER=1 enables real
|
||
// container execution. Default is off so the regular suite stays fast
|
||
// and runs without a Docker daemon. See e2e/README.md.
|
||
const dockerEnabled = isContainerProjectActive(workerInfo.project.name);
|
||
const mockAgentLinuxBinary = path.join(BACKEND_DIR, "bin", "mock-agent-linux-amd64");
|
||
const agentctlLinuxBinary = path.join(BACKEND_DIR, "bin", "agentctl-linux-amd64");
|
||
|
||
const backendEnv = {
|
||
...sanitizeInheritedEnv(process.env as Record<string, string>),
|
||
// Prepend the kandev bin dir so the host utility probe can locate
|
||
// the `mock-agent` binary via PATH. In production that dir is the
|
||
// same as the running kandev binary's dir, but e2e spawns via an
|
||
// absolute path and doesn't inherit that location.
|
||
PATH: `${shimDir}:${path.join(BACKEND_DIR, "bin")}:${originalPath}`,
|
||
KANDEV_E2E_ORIGINAL_PATH: originalPath,
|
||
KANDEV_E2E_GIT_DELAY_FILE: shimDelayFile,
|
||
KANDEV_E2E_GITLAB_PUSH_FILE: shimGitLabPushFile,
|
||
KANDEV_E2E_GITLAB_PUSH_RECORD_FILE: shimGitLabPushRecordFile,
|
||
KANDEV_E2E_GITLAB_REMOTE_URL: `http://localhost:${backendPort}/platform/kandev.git`,
|
||
HOME: tmpDir,
|
||
KANDEV_HOME_DIR: homeDir,
|
||
KANDEV_SERVER_PORT: String(backendPort),
|
||
KANDEV_WEB_DIST_DIR: WEB_DIST_DIR,
|
||
KANDEV_DATABASE_PATH: dbPath,
|
||
// Profile selector. KANDEV_E2E_MOCK=true tells the backend to
|
||
// apply the `e2e:` profile from profiles.yaml at startup —
|
||
// which sets the mock agent and third-party provider flags,
|
||
// KANDEV_FEATURES_OFFICE, AGENTCTL_AUTO_APPROVE_PERMISSIONS,
|
||
// KANDEV_PLAN_COALESCE_WINDOW_MS, etc. We don't re-set those
|
||
// here. KANDEV_MOCK_PROVIDERS stays opt-in per-spec because it
|
||
// changes agent counts; the five office-routing-* specs pass
|
||
// it to backend.restart() when needed (see
|
||
// registry.RoutableProviderIDs).
|
||
KANDEV_E2E_MOCK: "true",
|
||
KANDEV_DOCKER_ENABLED: dockerEnabled ? "true" : "false",
|
||
// When Docker is on, point the lifecycle resolvers at the linux/amd64
|
||
// binaries the test runner pre-built, so containers can bind-mount them.
|
||
...(dockerEnabled
|
||
? {
|
||
KANDEV_AGENTCTL_LINUX_BINARY: agentctlLinuxBinary,
|
||
KANDEV_MOCK_AGENT_LINUX_BINARY: mockAgentLinuxBinary,
|
||
}
|
||
: {}),
|
||
KANDEV_WORKTREE_ENABLED: "true",
|
||
KANDEV_WORKTREE_BASEPATH: worktreeBase,
|
||
KANDEV_REPOCLONE_BASEPATH: repoCloneBase,
|
||
KANDEV_LOG_LEVEL: process.env.KANDEV_LOG_LEVEL ?? "warn",
|
||
AGENTCTL_INSTANCE_PORT_BASE: String(agentctlPortBase),
|
||
AGENTCTL_INSTANCE_PORT_MAX: String(agentctlPortMax),
|
||
// AGENTCTL_AUTO_APPROVE_PERMISSIONS=true and
|
||
// KANDEV_PLAN_COALESCE_WINDOW_MS=2000 are applied by the
|
||
// backend's profile loader (profiles.yaml `e2e:` column).
|
||
// Specs that need different values (e.g.
|
||
// permission-approval.spec.ts setting auto-approve=false) set
|
||
// process.env.X before spawn — that already flows through the
|
||
// `...sanitizeInheritedEnv(process.env)` spread above, and the
|
||
// backend's ApplyProfile leaves already-set vars alone. (Note:
|
||
// KANDEV_FEATURES_* is the exception — it's stripped from the
|
||
// inherited env so the profile always governs feature flags.)
|
||
GIT_AUTHOR_NAME: "E2E Test",
|
||
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
||
GIT_COMMITTER_NAME: "E2E Test",
|
||
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
||
};
|
||
|
||
const debug = !!process.env.E2E_DEBUG;
|
||
const baseUrl = `http://localhost:${backendPort}`;
|
||
|
||
// Snapshot the baseline env so `restart(envOverrides)` rebuilds from
|
||
// a clean copy each call instead of accumulating leftover keys (e.g.
|
||
// KANDEV_MOCK_PROVIDERS, KANDEV_PROVIDER_FAILURES) from prior tests.
|
||
const baselineEnv = { ...backendEnv } as Record<string, string>;
|
||
|
||
// --- Spawn backend ---
|
||
backendProc = spawnBackendProcess(backendEnv, debug, backendPort);
|
||
registerProcess(backendProc);
|
||
await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS, backendProc);
|
||
const frontendUrl = baseUrl;
|
||
|
||
/**
|
||
* Kill the backend process group and respawn with the same config.
|
||
* SQLite DB, tmpDir, and all persisted data survive the restart.
|
||
* Only in-memory execution state (running agents, WS connections) is lost.
|
||
*/
|
||
const restart = async (envOverrides?: Record<string, string>) => {
|
||
// Rebuild from the baseline snapshot so a previous restart's
|
||
// overrides don't leak into this one (e.g. KANDEV_MOCK_PROVIDERS
|
||
// set by the routing specs would otherwise stick for the rest of
|
||
// the worker's lifetime and register canonical agent IDs that
|
||
// sibling specs count).
|
||
const nextEnv: Record<string, string> = { ...baselineEnv };
|
||
if (envOverrides) {
|
||
for (const [k, v] of Object.entries(envOverrides)) {
|
||
nextEnv[k] = v;
|
||
}
|
||
}
|
||
const runningProcess = backendProc;
|
||
if (!runningProcess) throw new Error("Backend process is not running");
|
||
await killProcessGroup(runningProcess);
|
||
// Poll until the OS releases the TCP port rather than sleeping a fixed
|
||
// 2 s. TIME_WAIT can linger for 30–120 s under load; the probe exits
|
||
// as soon as the port stops accepting connections (typically <200 ms).
|
||
await waitForPortFree(backendPort);
|
||
backendProc = spawnBackendProcess(nextEnv, debug, backendPort);
|
||
registerProcess(backendProc);
|
||
// Pass the process so waitForHealth fails fast if it exits (e.g. port still in use)
|
||
await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS, backendProc);
|
||
};
|
||
|
||
await use({ port: backendPort, baseUrl, frontendPort, frontendUrl, tmpDir, restart });
|
||
});
|
||
},
|
||
{ scope: "worker", timeout: 60_000 },
|
||
],
|
||
});
|
||
|
||
/** Strip GH_TOKEN / GITHUB_TOKEN so the mock client is used. */
|
||
// Sanitize the inherited environment before handing it to the e2e backend.
|
||
// Two classes of vars must not leak through the `...process.env` spread:
|
||
// - GitHub tokens — tests must hit the mock GitHub, never a real token.
|
||
// - KANDEV_FEATURES_* flags — these are profile-managed (profiles.yaml `e2e:`
|
||
// column turns them on). When the suite is launched from inside a kandev
|
||
// task, the parent process exports KANDEV_FEATURES_OFFICE=false; left in
|
||
// place it survives the spread and, because the backend's ApplyProfile
|
||
// leaves already-set vars alone, disables Office (and any future feature)
|
||
// in the test backend → /api/v1/office/* 404s. Dropping the whole
|
||
// KANDEV_FEATURES_* namespace lets the e2e profile govern feature flags so
|
||
// the suite always exercises them, regardless of where it's launched.
|
||
function sanitizeInheritedEnv(env: Record<string, string>): Record<string, string> {
|
||
const cleaned = { ...env };
|
||
delete cleaned.GH_TOKEN;
|
||
delete cleaned.GITHUB_TOKEN;
|
||
for (const key of Object.keys(cleaned)) {
|
||
if (key.startsWith("KANDEV_FEATURES_")) delete cleaned[key];
|
||
}
|
||
return cleaned;
|
||
}
|