test: run release v2 QA from shared standalone build

This commit is contained in:
ajnart committed 2026-09-01 06:23:19 +02:00
1 parent b40468f37c
commit a149dbdfab
9 files changed
+266 -91

No files matched your search

+3 -3
View File
@@ -144,9 +144,9 @@ const reportsRoot = join(qaRoot, "reports");
const campaignCandidateSha = resolveCheckoutCandidateSha(repoRoot);
const runtimeExecutionContract = createRuntimeExecutionContract();
export const hostRuntimeLimitation =
`The host's fs.inotify.max_user_instances=128 is too low for concurrent native Watchpack watchers. ` +
`Spawned QA apps use the ${runtimeExecutionContract.bundler} bundler with ${runtimeExecutionContract.watcher.watchpackPollingIntervalMs}ms Watchpack polling; ` +
"filesystem changes can take up to one polling interval to reach HMR.";
`The host's fs.inotify.max_user_instances=128 is too low for concurrent development watchers. ` +
`QA apps use ${runtimeExecutionContract.runtimeMode} from a single candidate-pinned build produced by the ${runtimeExecutionContract.bundler} bundler. ` +
"Source changes require a new committed candidate build before browser evidence is valid.";
const expectedProfileFlags: Record<string, string[]> = {
"main-writable": ["DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
"main-readonly": ["DEMO_MODE=true", "DEMO_READ_ONLY=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
+4 -4
View File
@@ -21,13 +21,13 @@ const candidateSha = resolveCheckoutCandidateSha(repoRoot);
const blockerFingerprint = "release-v2-auth-locale-redirect-loop";
describe("release-v2 QA report runtime limitation", () => {
it("describes the bundler and polling interval from the runtime contract", () => {
it("describes the shared standalone runtime contract", () => {
const runtimeExecutionContract = createRuntimeExecutionContract();
expect(hostRuntimeLimitation).toContain(`the ${runtimeExecutionContract.bundler} bundler`);
expect(hostRuntimeLimitation).toContain(
`${runtimeExecutionContract.watcher.watchpackPollingIntervalMs}ms Watchpack polling`,
);
expect(hostRuntimeLimitation).toContain(runtimeExecutionContract.runtimeMode);
expect(hostRuntimeLimitation).toContain("single candidate-pinned build");
expect(hostRuntimeLimitation).not.toContain("Watchpack polling");
expect(hostRuntimeLimitation).not.toContain("Turbopack");
});
});
+149 -39
View File
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { closeSync, constants, existsSync, openSync } from "node:fs";
import { chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
import { chmod, cp, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -8,6 +8,7 @@ import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
acquireBuildLease,
acquireSlotLease,
assertSafeContainedPath,
assertSafeRunRoot,
@@ -19,8 +20,9 @@ import {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const defaultRunRoot = path.join(tmpdir(), "homarr-release-v2-qa");
const markerName = ".homarr-release-v2-qa-root.json";
const watchpackPollingIntervalMs = 1_000;
const runtimeMode = "production-standalone" as const;
const runtimeBundler = "webpack" as const;
const candidateBuildHeapLimitMb = 16_384;
const profiles = ["main-writable", "main-readonly", "onboarding-fresh", "degraded"] as const;
type Profile = (typeof profiles)[number];
@@ -32,50 +34,65 @@ export const assertRuntimeCommandPlatform = (command: RuntimeCommand, platform:
};
interface RuntimeExecutionContract {
runtimeMode: typeof runtimeMode;
bundler: typeof runtimeBundler;
watcher: { watchpackPollingIntervalMs: number };
watcher: null;
}
export const createRuntimeExecutionContract = (): RuntimeExecutionContract => ({
runtimeMode,
bundler: runtimeBundler,
watcher: { watchpackPollingIntervalMs },
watcher: null,
});
export const validateRuntimeExecutionContract = (value: {
runtimeMode?: unknown;
bundler?: unknown;
watcher?: { watchpackPollingIntervalMs?: unknown };
watcher?: unknown;
}): string[] => {
const contractErrors: string[] = [];
if (value.runtimeMode !== runtimeMode) contractErrors.push("runtime mode must be production-standalone");
if (value.bundler !== runtimeBundler) contractErrors.push("bundler must be webpack");
if (value.watcher?.watchpackPollingIntervalMs !== watchpackPollingIntervalMs) {
contractErrors.push("Watchpack polling interval must be 1000ms");
if (value.watcher !== null && value.watcher !== undefined) {
contractErrors.push("standalone runtimes must not use a filesystem watcher");
}
return contractErrors;
};
export const createAppLaunchCommand = (
appPort: number,
options: { platform?: NodeJS.Platform; prlimitAvailable?: boolean } = {},
serverPath: string,
options: { platform?: NodeJS.Platform; prlimitAvailable?: boolean; nodeExecutable?: string } = {},
) => {
const platform = options.platform ?? process.platform;
const prlimitAvailable = options.prlimitAvailable ?? existsSync("/usr/bin/prlimit");
const pnpmArguments = [
"--filter",
"@homarr/nextjs",
"dev",
"--webpack",
"--hostname",
"127.0.0.1",
"--port",
String(appPort),
];
const nodeExecutable = options.nodeExecutable ?? process.execPath;
if (platform === "linux" && prlimitAvailable) {
return {
command: "/usr/bin/prlimit",
arguments: ["--nofile=65536:1048576", "--", "pnpm", ...pnpmArguments],
arguments: ["--nofile=65536:1048576", "--", nodeExecutable, serverPath],
};
}
return { command: "pnpm", arguments: pnpmArguments };
return { command: nodeExecutable, arguments: [serverPath] };
};
export const createCandidateBuildPaths = (candidateSha: string) => {
if (!/^[0-9a-f]{40}$/u.test(candidateSha)) {
throw new Error("Candidate build requires a 40-character lowercase commit SHA");
}
const appRoot = path.join(repoRoot, "apps/nextjs");
const distDirName = `.next-qa/release-v2-${candidateSha}`;
const buildDir = path.join(appRoot, distDirName);
const standaloneRoot = path.join(buildDir, "standalone");
const standaloneAppDir = path.join(standaloneRoot, "apps/nextjs");
return {
appRoot,
distDirName,
buildDir,
standaloneRoot,
standaloneAppDir,
serverPath: path.join(standaloneAppDir, "server.js"),
markerPath: path.join(buildDir, "qa-build-manifest.json"),
};
};
interface Options {
@@ -107,8 +124,10 @@ interface RuntimeManifest {
fixtureUrl: string;
ports: { app: number; fixture: number; redis: number };
flags: { demoMode: boolean; demoReadOnly: boolean; unsafeMockIntegration: boolean };
runtimeMode: typeof runtimeMode;
bundler: typeof runtimeBundler;
watcher: { watchpackPollingIntervalMs: number };
watcher: null;
build: { candidateSha: string; serverPath: string };
processes: {
app: { pid: number; logPath: string };
fixture: { pid: number; logPath: string };
@@ -325,14 +344,20 @@ interface SpawnedProcess {
exited: Promise<number | null>;
}
const spawnLogged = (command: string, args: string[], logPath: string, env: NodeJS.ProcessEnv): SpawnedProcess => {
const spawnLogged = (
command: string,
args: string[],
logPath: string,
env: NodeJS.ProcessEnv,
options: { cwd?: string } = {},
): SpawnedProcess => {
const output = openSync(
logPath,
constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | constants.O_NOFOLLOW,
0o600,
);
const child = spawn(command, args, {
cwd: repoRoot,
cwd: options.cwd ?? repoRoot,
detached: true,
env,
stdio: ["ignore", output, output],
@@ -352,6 +377,75 @@ const runForeground = (command: string, args: string[], env: NodeJS.ProcessEnv)
throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}`);
};
const candidateBuildIsReady = async (candidateSha: string) => {
const buildPaths = createCandidateBuildPaths(candidateSha);
try {
const marker = JSON.parse(await readFile(buildPaths.markerPath, "utf8")) as {
schemaVersion?: number;
candidateSha?: string;
runtimeMode?: string;
bundler?: string;
};
const serverStats = await stat(buildPaths.serverPath);
const staticStats = await stat(path.join(buildPaths.standaloneAppDir, ".next/static"));
const publicStats = await stat(path.join(buildPaths.standaloneAppDir, "public"));
return (
marker.schemaVersion === 1 &&
marker.candidateSha === candidateSha &&
marker.runtimeMode === runtimeMode &&
marker.bundler === runtimeBundler &&
serverStats.isFile() &&
staticStats.isDirectory() &&
publicStats.isDirectory()
);
} catch {
return false;
}
};
const ensureCandidateBuild = async (runRoot: string, candidateSha: string) => {
const buildPaths = createCandidateBuildPaths(candidateSha);
if (await candidateBuildIsReady(candidateSha)) return buildPaths;
const buildLease = await acquireBuildLease(runRoot, `build:${candidateSha}`);
try {
if (await candidateBuildIsReady(candidateSha)) return buildPaths;
await assertSafeContainedPath(buildPaths.appRoot, buildPaths.buildDir, "QA candidate build directory");
await rm(buildPaths.buildDir, { recursive: true, force: true });
await assertSafeContainedPath(buildPaths.appRoot, buildPaths.buildDir, "QA candidate build directory");
runForeground("pnpm", ["--filter", "@homarr/nextjs", "with-env", "next", "build", "--webpack"], {
...sanitizeAppEnvironment(process.env),
CI: "true",
DISABLE_REDIS_LOGS: "true",
HOMARR_QA_NEXT_DIST_DIR: buildPaths.distDirName,
HOMARR_QA_STANDALONE_BUILD: "true",
HOMARR_VERSION: candidateSha.slice(0, 12),
NEXT_TELEMETRY_DISABLED: "1",
NODE_OPTIONS: `--max-old-space-size=${candidateBuildHeapLimitMb}`,
SKIP_ENV_VALIDATION: "true",
});
const staticTarget = path.join(buildPaths.standaloneAppDir, ".next/static");
const publicTarget = path.join(buildPaths.standaloneAppDir, "public");
await mkdir(path.dirname(staticTarget), { recursive: true });
await cp(path.join(buildPaths.buildDir, "static"), staticTarget, { recursive: true, force: true });
await cp(path.join(buildPaths.appRoot, "public"), publicTarget, { recursive: true, force: true });
const serverStats = await stat(buildPaths.serverPath);
if (!serverStats.isFile()) throw new Error(`Standalone server is missing after build: ${buildPaths.serverPath}`);
await writeJsonAtomic(buildPaths.markerPath, {
schemaVersion: 1,
candidateSha,
runtimeMode,
bundler: runtimeBundler,
builtAt: new Date().toISOString(),
});
return buildPaths;
} finally {
await buildLease.release();
}
};
const isProcessRunning = (pid: number) => {
try {
process.kill(pid, 0);
@@ -503,6 +597,7 @@ const startWithLease = async (options: Options) => {
} else {
await assertRunRootMarker(options.runRoot);
}
const candidateBuild = await ensureCandidateBuild(options.runRoot, candidateSha);
const slotsRoot = path.join(options.runRoot, "slots");
await assertSafeContainedPath(options.runRoot, slotsRoot, "QA slots directory");
@@ -525,11 +620,19 @@ const startWithLease = async (options: Options) => {
await rm(slotDir, { recursive: true });
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot reset target");
}
const nextDistDir = path.join(repoRoot, "apps/nextjs/.next-qa", `slot-${options.slot}`);
const legacyNextDistDir = path.join(repoRoot, "apps/nextjs/.next-qa", `slot-${options.slot}`);
if (options.reset) {
await assertSafeContainedPath(path.join(repoRoot, "apps/nextjs"), nextDistDir, "QA Next cache reset target");
await rm(nextDistDir, { recursive: true, force: true });
await assertSafeContainedPath(path.join(repoRoot, "apps/nextjs"), nextDistDir, "QA Next cache reset target");
await assertSafeContainedPath(
path.join(repoRoot, "apps/nextjs"),
legacyNextDistDir,
"legacy QA Next cache reset target",
);
await rm(legacyNextDistDir, { recursive: true, force: true });
await assertSafeContainedPath(
path.join(repoRoot, "apps/nextjs"),
legacyNextDistDir,
"legacy QA Next cache reset target",
);
}
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot creation target");
await mkdir(slotDir, { mode: 0o700 });
@@ -619,7 +722,7 @@ const startWithLease = async (options: Options) => {
HOMARR_WEBSITE_URL: url,
WORKSHOP_API_URL: fixtureReady.url,
WORKSHOP_WEB_URL: `${fixtureReady.url}/iframe`,
HOMARR_QA_NEXT_DIST_DIR: `.next-qa/slot-${options.slot}`,
HOMARR_QA_NEXT_DIST_DIR: candidateBuild.distDirName,
HOMARR_VERSION: candidateSha.slice(0, 12),
NEXT_TELEMETRY_DISABLED: "1",
};
@@ -631,24 +734,30 @@ const startWithLease = async (options: Options) => {
runForeground("pnpm", ["qa:release-v2:seed", "--", "--profile", options.profile, "--output", slotDir], environment);
await assertSafeContainedPath(options.runRoot, fixtureManifestPath, "QA fixture manifest");
const appEnvironment = {
...sanitizeAppEnvironment(environment),
WATCHPACK_POLLING: String(watchpackPollingIntervalMs),
};
const appEnvironment = sanitizeAppEnvironment(environment);
await assertSafeContainedPath(options.runRoot, appLogPath, "QA app log");
const launchedApp = await launchWithPortRetry<{ pid: number; port: number; url: string }>(
async (attemptPort) => {
appPort = attemptPort;
url = `http://127.0.0.1:${appPort}`;
const appLaunch = createAppLaunchCommand(appPort);
const appLaunch = createAppLaunchCommand(candidateBuild.serverPath);
const logOffset = await stat(appLogPath)
.then((stats) => stats.size)
.catch(() => 0);
await assertSafeContainedPath(options.runRoot, appLogPath, "QA app log");
const spawned = spawnLogged(appLaunch.command, appLaunch.arguments, appLogPath, {
...appEnvironment,
HOMARR_WEBSITE_URL: url,
});
const spawned = spawnLogged(
appLaunch.command,
appLaunch.arguments,
appLogPath,
{
...appEnvironment,
HOSTNAME: "127.0.0.1",
HOMARR_WEBSITE_URL: url,
NODE_ENV: "production",
PORT: String(appPort),
},
{ cwd: candidateBuild.standaloneRoot },
);
appPid = spawned.pid;
const outcome = await waitForAppAttempt(spawned, url, appLogPath, logOffset, 300_000);
if (outcome === "address-in-use") {
@@ -678,12 +787,13 @@ const startWithLease = async (options: Options) => {
slotDir,
dbPath,
fixtureManifestPath,
nextDistDir,
nextDistDir: candidateBuild.buildDir,
url,
fixtureUrl: fixtureReady.url,
ports: { app: appPort, fixture: fixtureReady.port, redis: redisPort },
flags,
...createRuntimeExecutionContract(),
build: { candidateSha, serverPath: candidateBuild.serverPath },
processes: {
app: { pid: appPid, logPath: appLogPath },
fixture: { pid: fixturePid, logPath: fixtureLogPath },
+37 -22
View File
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from "vitest";
import {
assertRuntimeCommandPlatform,
createAppLaunchCommand,
createCandidateBuildPaths,
createRuntimeExecutionContract,
launchWithPortRetry,
reserveLoopbackPort,
@@ -88,44 +89,58 @@ describe("release-v2 QA runtime platform boundary", () => {
});
describe("release-v2 QA app execution contract", () => {
test("uses Webpack dev while preserving the Linux file-descriptor wrapper", () => {
const launch = createAppLaunchCommand(31_337, { platform: "linux", prlimitAvailable: true });
const launchWithoutPrlimit = createAppLaunchCommand(31_337, { platform: "linux", prlimitAvailable: false });
test("uses the candidate standalone server while preserving the Linux file-descriptor wrapper", () => {
const serverPath = "/tmp/release-v2-build/standalone/apps/nextjs/server.js";
const launch = createAppLaunchCommand(serverPath, {
platform: "linux",
prlimitAvailable: true,
nodeExecutable: "/usr/bin/node",
});
const launchWithoutPrlimit = createAppLaunchCommand(serverPath, {
platform: "linux",
prlimitAvailable: false,
nodeExecutable: "/usr/bin/node",
});
expect(launch).toEqual({
command: "/usr/bin/prlimit",
arguments: [
"--nofile=65536:1048576",
"--",
"pnpm",
"--filter",
"@homarr/nextjs",
"dev",
"--webpack",
"--hostname",
"127.0.0.1",
"--port",
"31337",
],
arguments: ["--nofile=65536:1048576", "--", "/usr/bin/node", serverPath],
});
expect(launchWithoutPrlimit.command).toBe("pnpm");
expect(launchWithoutPrlimit.arguments).toContain("--webpack");
expect(launchWithoutPrlimit).toEqual({ command: "/usr/bin/node", arguments: [serverPath] });
});
test("records and validates the deterministic bundler and watcher settings", () => {
test("records and validates the deterministic standalone build settings", () => {
const execution = createRuntimeExecutionContract();
expect(execution).toEqual({
runtimeMode: "production-standalone",
bundler: "webpack",
watcher: { watchpackPollingIntervalMs: 1_000 },
watcher: null,
});
expect(validateRuntimeExecutionContract(execution)).toEqual([]);
expect(
validateRuntimeExecutionContract({
runtimeMode: "development",
bundler: "turbopack",
watcher: { watchpackPollingIntervalMs: 500 },
}),
).toEqual(["bundler must be webpack", "Watchpack polling interval must be 1000ms"]);
expect(validateRuntimeExecutionContract({ watcher: execution.watcher })).toEqual(["bundler must be webpack"]);
).toEqual([
"runtime mode must be production-standalone",
"bundler must be webpack",
"standalone runtimes must not use a filesystem watcher",
]);
expect(validateRuntimeExecutionContract({ watcher: execution.watcher })).toEqual([
"runtime mode must be production-standalone",
"bundler must be webpack",
]);
});
test("derives one candidate-pinned build shared by every runtime slot", () => {
const paths = createCandidateBuildPaths("0123456789abcdef0123456789abcdef01234567");
expect(paths.distDirName).toBe(".next-qa/release-v2-0123456789abcdef0123456789abcdef01234567");
expect(paths.serverPath).toBe(`${paths.buildDir}/standalone/apps/nextjs/server.js`);
expect(paths.standaloneRoot).toBe(`${paths.buildDir}/standalone`);
expect(() => createCandidateBuildPaths("not-a-commit")).toThrow(/40-character lowercase commit SHA/u);
});
});
+38 -9
View File
@@ -179,6 +179,12 @@ export const getSlotLeasePath = async (runRoot: string, slot: number) => {
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.slot-${slot}.lease`);
};
export const getBuildLeasePath = async (runRoot: string) => {
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
const canonicalParent = await canonicalTemporaryParent();
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.candidate-build.lease`);
};
const readLeaseSnapshot = async (leasePath: string): Promise<LeaseSnapshot> => {
const stats = await lstat(leasePath, { bigint: true });
if (stats.isSymbolicLink() || !stats.isFile()) {
@@ -234,16 +240,16 @@ const recoverStaleLease = async (leasePath: string, snapshot: LeaseSnapshot) =>
await unlink(leasePath);
};
export const acquireSlotLease = async (
const acquireLease = async (
runRoot: string,
slot: number,
operation: string,
leasePath: string,
resourceLabel: string,
platform: NodeJS.Platform = process.platform,
): Promise<SlotLease> => {
assertSupportedRuntimePlatform(platform);
if (![1, 2, 3].includes(slot)) throw new Error("QA slot lease requires slot 1, 2, or 3");
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
const leasePath = await getSlotLeasePath(canonicalRunRoot, slot);
const owner: SlotLeaseOwner = {
schemaVersion: leaseSchemaVersion,
pid: process.pid,
@@ -271,7 +277,7 @@ export const acquireSlotLease = async (
release: async () => {
const snapshot = await readLeaseSnapshot(leasePath);
if (!isValidLeaseOwner(snapshot.owner, canonicalRunRoot, slot) || snapshot.owner?.token !== owner.token) {
throw new Error(`Refusing to release a QA slot lease owned by another process: ${leasePath}`);
throw new Error(`Refusing to release a QA ${resourceLabel} lease owned by another process: ${leasePath}`);
}
await recoverStaleLease(leasePath, snapshot);
},
@@ -282,21 +288,44 @@ export const acquireSlotLease = async (
const existingOwner = snapshot.owner;
if (!isValidLeaseOwner(existingOwner, canonicalRunRoot, slot)) {
if (Date.now() - snapshot.modifiedMs < incompleteLeaseGraceMs) {
throw new Error(`QA slot ${slot} lease is being initialized by another operation`, { cause: error });
throw new Error(`QA ${resourceLabel} lease is being initialized by another operation`, { cause: error });
}
await recoverStaleLease(leasePath, snapshot);
continue;
}
if (await processMatchesLeaseOwner(existingOwner)) {
throw new Error(`QA slot ${slot} is busy with ${existingOwner.operation} owned by PID ${existingOwner.pid}`, {
cause: error,
});
throw new Error(
`QA ${resourceLabel} is busy with ${existingOwner.operation} owned by PID ${existingOwner.pid}`,
{
cause: error,
},
);
}
await recoverStaleLease(leasePath, snapshot);
}
}
throw new Error(`Unable to acquire QA slot ${slot} lease after stale recovery`);
throw new Error(`Unable to acquire QA ${resourceLabel} lease after stale recovery`);
};
export const acquireSlotLease = async (
runRoot: string,
slot: number,
operation: string,
platform: NodeJS.Platform = process.platform,
) => {
if (![1, 2, 3].includes(slot)) throw new Error("QA slot lease requires slot 1, 2, or 3");
const leasePath = await getSlotLeasePath(runRoot, slot);
return await acquireLease(runRoot, slot, operation, leasePath, `slot ${slot}`, platform);
};
export const acquireBuildLease = async (
runRoot: string,
operation: string,
platform: NodeJS.Platform = process.platform,
) => {
const leasePath = await getBuildLeasePath(runRoot);
return await acquireLease(runRoot, 0, operation, leasePath, "candidate build", platform);
};
const normalizedHost = (host: string) => {
+12
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
acquireBuildLease,
acquireSlotLease,
assertSafeContainedPath,
assertSafeRunRoot,
@@ -116,6 +117,17 @@ describe("release-v2 QA harness safety", () => {
await lease.release();
});
test("serializes the shared candidate build independently of slot leases", async () => {
const runRoot = path.join(tmpdir(), uniqueName("build-lease"));
const buildLease = await acquireBuildLease(runRoot, "first-build");
const slotLease = await acquireSlotLease(runRoot, 1, "slot-start");
temporaryPaths.push(buildLease.path, slotLease.path);
await expect(acquireBuildLease(runRoot, "second-build")).rejects.toThrow(/candidate build is busy.*first-build/u);
await buildLease.release();
await slotLease.release();
});
test("recovers a stale lease with a dead owner PID", async () => {
const runRoot = path.join(tmpdir(), uniqueName("stale-lease"));
const leasePath = await getSlotLeasePath(runRoot, 2);
+13 -5
View File
@@ -9,7 +9,7 @@ import { findFixtureGeometryErrors } from "./geometry.mts";
import { resolveCheckoutCandidateSha } from "./provenance.mts";
import { collectHumanWidgetStatusErrors, normalizeReportMetadata } from "./report-integrity.mts";
import { assertSafeReportPath, readSafeReportFile, validateResolvedArtifactPath } from "./report-path-integrity.mts";
import { validateRuntimeExecutionContract } from "./runtime.mts";
import { createCandidateBuildPaths, validateRuntimeExecutionContract } from "./runtime.mts";
import { assertSafeRunRoot, isPathWithin, validateFixtureUrl } from "./safety.mts";
@@ -118,8 +118,10 @@ interface RuntimeManifest {
fixtureUrl: string;
ports: { app: number; fixture: number; redis: number };
flags: { demoMode: boolean; demoReadOnly: boolean; unsafeMockIntegration: boolean };
runtimeMode: string;
bundler: string;
watcher: { watchpackPollingIntervalMs: number };
watcher: unknown;
build: { candidateSha: string; serverPath: string };
processes: {
app: { pid: number; logPath: string };
fixture: { pid: number; logPath: string };
@@ -645,7 +647,7 @@ const validateRuntimeManifest = async (
const expectedManifestPath = join(expectedSlotDir, "runtime-manifest.json");
const expectedDbPath = join(expectedSlotDir, "db.sqlite");
const expectedFixtureManifestPath = join(expectedSlotDir, "fixture-manifest.json");
const expectedNextDistDir = join(repoRoot, "apps/nextjs/.next-qa", `slot-${runtime.slot}`);
const expectedBuild = createCandidateBuildPaths(campaignCandidateSha);
if (resolve(runtime.slotDir) !== resolve(expectedSlotDir))
errors.push(`${label}: slotDir is outside the selected slot`);
if (resolve(manifestPath) !== resolve(expectedManifestPath))
@@ -654,8 +656,14 @@ const validateRuntimeManifest = async (
if (resolve(runtime.fixtureManifestPath) !== resolve(expectedFixtureManifestPath)) {
errors.push(`${label}: fixture manifest path is outside the selected slot`);
}
if (resolve(runtime.nextDistDir) !== resolve(expectedNextDistDir)) {
errors.push(`${label}: Next dist directory is not isolated to the selected slot`);
if (resolve(runtime.nextDistDir) !== resolve(expectedBuild.buildDir)) {
errors.push(`${label}: Next dist directory is not the candidate-pinned shared build`);
}
if (
runtime.build?.candidateSha !== campaignCandidateSha ||
resolve(runtime.build?.serverPath ?? "") !== resolve(expectedBuild.serverPath)
) {
errors.push(`${label}: standalone build metadata does not match the campaign candidate`);
}
for (const processRecord of Object.values(runtime.processes ?? {})) {
if (!isPathWithin(expectedSlotDir, resolve(processRecord.logPath))) {