test: stabilize release v2 browser QA runtime
This commit is contained in:
1 parent
ec95d18245
commit
f6c4ecff5b
7 files changed
+606
-44
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, stat } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
aggregateQaFindings,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
writeSafeReportFile,
|
||||
} from "./report-path-integrity.mts";
|
||||
import { resolveCheckoutCandidateSha } from "./provenance.mts";
|
||||
import { createRuntimeExecutionContract } from "./runtime.mts";
|
||||
|
||||
type Status = "passed" | "failed" | "blocked" | "not-reached";
|
||||
type Severity = "P0" | "P1" | "P2" | "P3";
|
||||
@@ -105,12 +106,47 @@ interface IndependentReproduction {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface PlaceholderRefreshOptions<T> {
|
||||
read: (entry: T) => Promise<string>;
|
||||
isSafePlaceholder: (content: string, entry: T) => boolean;
|
||||
render: (entry: T) => string;
|
||||
write: (entry: T, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface PlaceholderRefreshCounts {
|
||||
refreshed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface ReportSnapshot {
|
||||
content: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ResolvedBlockerRolloverOptions<T, TSnapshot extends { content: string }> {
|
||||
read: (entry: T) => Promise<TSnapshot>;
|
||||
isEligible: (content: string, entry: T) => boolean;
|
||||
isUnchanged: (entry: T, snapshot: TSnapshot) => Promise<boolean>;
|
||||
render: (entry: T) => string;
|
||||
write: (entry: T, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface ResolvedBlockerRolloverCounts {
|
||||
reset: number;
|
||||
skipped: number;
|
||||
concurrentlyChanged: number;
|
||||
}
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const qaRoot = join(repoRoot, "qa/release-v2");
|
||||
const reportsRoot = join(qaRoot, "reports");
|
||||
const campaignCandidateSha = resolveCheckoutCandidateSha(repoRoot);
|
||||
const hostRuntimeLimitation =
|
||||
"The host's fs.inotify.max_user_instances=128 is too low for concurrent native Watchpack watchers. Spawned QA apps keep Turbopack and use 1000ms Watchpack polling; filesystem changes can take up to one polling interval to reach HMR.";
|
||||
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.";
|
||||
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"],
|
||||
@@ -139,9 +175,20 @@ const exhaustiveSizeWidgetKinds = new Set([
|
||||
const manifest = JSON.parse(await readFile(join(qaRoot, "coverage-manifest.json"), "utf8")) as Manifest;
|
||||
const init = process.argv.includes("--init");
|
||||
const refresh = process.argv.includes("--refresh-placeholders");
|
||||
const rolloverFlag = "--rollover-resolved-blocker";
|
||||
const rolloverFlagIndex = process.argv.indexOf(rolloverFlag);
|
||||
const rolloverResolvedBlocker = rolloverFlagIndex === -1 ? null : (process.argv[rolloverFlagIndex + 1] ?? null);
|
||||
|
||||
if (rolloverFlagIndex !== -1 && (!rolloverResolvedBlocker || rolloverResolvedBlocker.startsWith("--"))) {
|
||||
throw new Error(`${rolloverFlag} requires a finding fingerprint`);
|
||||
}
|
||||
if ([init, refresh, rolloverResolvedBlocker !== null].filter(Boolean).length > 1) {
|
||||
throw new Error("Use only one of --init, --refresh-placeholders, or --rollover-resolved-blocker");
|
||||
}
|
||||
|
||||
const escapeCell = (value: string): string => value.replaceAll("|", "\\|").replaceAll("\n", " ");
|
||||
const list = (values: (string | number)[]): string => (values.length === 0 ? "None" : values.join(", "));
|
||||
const isBlockedOrNotReached = (status: Status): boolean => status === "blocked" || status === "not-reached";
|
||||
|
||||
const expandedCases = (packet: Packet): { id: string; expected: string }[] => {
|
||||
const dimensions = manifest.caseDimensions[packet.wave] ?? [];
|
||||
@@ -215,7 +262,7 @@ const renderPacketReportBase = (packet: Packet): string => {
|
||||
return `<!-- release-v2-qa-report\n${JSON.stringify(metadata, null, 2)}\n-->\n\n# ${packet.id}: ${packet.title}\n\n| Metadata | Value |\n| --- | --- |\n| Status | not-reached |\n| Wave | ${packet.wave} |\n| PR refs | ${packet.prRefs.map((number) => `#${number}`).join(", ")} |\n| Personas | ${list(packet.personas)} |\n| Boards | ${list(packet.boards)} |\n| Profiles | ${list(packet.profiles)} |\n| Chromium viewports | ${list(packet.viewports)} |\n| Zoom | ${list(packet.zooms.map((zoom) => `${zoom}%`))} |\n| Input | ${list(packet.inputs)} |\n| Widget kinds | ${list(packet.widgetKinds ?? [])} |\n| Artifact directory | [${artifactDirectory}](${artifactDirectory}) |\n| Candidate SHA | not-recorded |\n| URL / actual port | not-recorded |\n| Runtime profile / flags | not-recorded |\n| Executed persona | not-recorded |\n| Browser session ID | not-recorded |\n| Timestamp | not-recorded |\n| Executed viewport / input / zoom | not-recorded |\n\n## Dogfood evidence\n\n| Case ID | Status | Evidence | Observed | Expected |\n| --- | --- | --- | --- | --- |\n${cases}\n${widgetSection}\n## Findings\n\nNo findings recorded. Add structured findings to the metadata block and mirror them here.\n\n## Blockers and gaps\n\nNot executed.\n\n## Cleanup\n\nRecord created resources, cleanup outcome, console errors, and network failures. Never include secret values.\n`;
|
||||
};
|
||||
|
||||
const renderPacketReport = (packet: Packet): string =>
|
||||
export const renderPacketReport = (packet: Packet): string =>
|
||||
renderPacketReportBase(packet)
|
||||
.replace("| Candidate SHA | not-recorded |", `| Candidate SHA | ${campaignCandidateSha} |`)
|
||||
.replace(
|
||||
@@ -247,6 +294,104 @@ const parseMetadata = (content: string, packet: Packet): { metadata: ReportMetad
|
||||
};
|
||||
};
|
||||
|
||||
export const reportMetadataHasEvidence = (metadata: ReportMetadata): boolean => {
|
||||
const execution = metadata.execution;
|
||||
const hasExecutionMetadata =
|
||||
execution.url !== null ||
|
||||
execution.actualPort !== null ||
|
||||
execution.runtimeProfile !== null ||
|
||||
execution.runtimeFlags.length > 0 ||
|
||||
execution.persona !== null ||
|
||||
execution.sessionId !== null ||
|
||||
execution.timestamp !== null ||
|
||||
execution.viewport !== null ||
|
||||
execution.input !== null ||
|
||||
execution.zoom !== null;
|
||||
|
||||
return (
|
||||
metadata.status !== "not-reached" ||
|
||||
Object.values(metadata.caseStatuses).some((status) => status !== "not-reached") ||
|
||||
metadata.widgetChecks.some((check) => check.status !== "not-reached") ||
|
||||
metadata.findings.length > 0 ||
|
||||
metadata.artifacts.length > 0 ||
|
||||
metadata.performance.measurements.length > 0 ||
|
||||
metadata.performance.limitations.length > 0 ||
|
||||
metadata.independentReproductions.length > 0 ||
|
||||
hasExecutionMetadata
|
||||
);
|
||||
};
|
||||
|
||||
export const reportIsOnlyBlockedBy = (metadata: ReportMetadata, findingFingerprint: string): boolean => {
|
||||
if (!isBlockedOrNotReached(metadata.status)) return false;
|
||||
if (!Object.values(metadata.caseStatuses).every(isBlockedOrNotReached)) return false;
|
||||
if (!metadata.widgetChecks.every((check) => isBlockedOrNotReached(check.status))) return false;
|
||||
if (metadata.performance.measurements.length > 0) return false;
|
||||
if (metadata.findings.length === 0) return false;
|
||||
if (metadata.findings.some((finding) => finding.fingerprint !== findingFingerprint)) return false;
|
||||
if (metadata.independentReproductions.some((reproduction) => reproduction.findingFingerprint !== findingFingerprint))
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const reportContentIsOnlyBlockedBy = (content: string, packet: Packet, findingFingerprint: string): boolean => {
|
||||
const parsed = parseMetadata(content, packet);
|
||||
return parsed.errors.length === 0 && reportIsOnlyBlockedBy(parsed.metadata, findingFingerprint);
|
||||
};
|
||||
|
||||
export const rolloverResolvedBlockerReports = async <T, TSnapshot extends { content: string }>(
|
||||
entries: T[],
|
||||
options: ResolvedBlockerRolloverOptions<T, TSnapshot>,
|
||||
): Promise<ResolvedBlockerRolloverCounts> => {
|
||||
const counts: ResolvedBlockerRolloverCounts = { reset: 0, skipped: 0, concurrentlyChanged: 0 };
|
||||
for (const entry of entries) {
|
||||
let snapshot: TSnapshot;
|
||||
try {
|
||||
snapshot = await options.read(entry);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!options.isEligible(snapshot.content, entry)) {
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (!(await options.isUnchanged(entry, snapshot))) {
|
||||
counts.concurrentlyChanged += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await options.write(entry, options.render(entry));
|
||||
counts.reset += 1;
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
export const refreshPlaceholderReports = async <T,>(
|
||||
entries: T[],
|
||||
options: PlaceholderRefreshOptions<T>,
|
||||
): Promise<PlaceholderRefreshCounts> => {
|
||||
const counts: PlaceholderRefreshCounts = { refreshed: 0, skipped: 0 };
|
||||
for (const entry of entries) {
|
||||
let existingContent: string | null = null;
|
||||
try {
|
||||
existingContent = await options.read(entry);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
if (existingContent !== null && !options.isSafePlaceholder(existingContent, entry)) {
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await options.write(entry, options.render(entry));
|
||||
counts.refreshed += 1;
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const collectHumanReportErrors = (content: string, packet: Packet, metadata: ReportMetadata): string[] => {
|
||||
const errors: string[] = [];
|
||||
if (!content.includes(`| Status | ${metadata.status} |`)) {
|
||||
@@ -323,33 +468,72 @@ const collectArtifactErrors = async (packet: Packet, metadata: ReportMetadata):
|
||||
};
|
||||
|
||||
const ensureReports = async (): Promise<void> => {
|
||||
if (refresh || rolloverResolvedBlocker) {
|
||||
for (const packet of manifest.packets) {
|
||||
const packetDirectory = join(reportsRoot, packet.id);
|
||||
const reportPath = join(packetDirectory, "report.md");
|
||||
await assertSafeReportPath(qaRoot, reportPath, `${packet.id} report path`);
|
||||
await mkdir(packetDirectory, { recursive: true });
|
||||
await assertSafeReportPath(qaRoot, reportPath, `${packet.id} report path`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rolloverResolvedBlocker) {
|
||||
const reportSnapshot = async (packet: Packet): Promise<ReportSnapshot> => {
|
||||
const reportPath = join(reportsRoot, packet.id, "report.md");
|
||||
const content = await readSafeReportFile(qaRoot, reportPath, `${packet.id} report path`);
|
||||
const metadata = await stat(reportPath);
|
||||
return { content, mtimeMs: metadata.mtimeMs, size: metadata.size };
|
||||
};
|
||||
const counts = await rolloverResolvedBlockerReports(manifest.packets, {
|
||||
read: reportSnapshot,
|
||||
isEligible: (content, packet) => reportContentIsOnlyBlockedBy(content, packet, rolloverResolvedBlocker),
|
||||
isUnchanged: async (packet, snapshot) => {
|
||||
const current = await reportSnapshot(packet);
|
||||
return (
|
||||
current.content === snapshot.content && current.mtimeMs === snapshot.mtimeMs && current.size === snapshot.size
|
||||
);
|
||||
},
|
||||
render: renderPacketReport,
|
||||
write: async (packet, content) => {
|
||||
const reportPath = join(reportsRoot, packet.id, "report.md");
|
||||
await writeSafeReportFile(qaRoot, reportPath, content, `${packet.id} report path`);
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`release-v2 QA resolved-blocker rollover (${rolloverResolvedBlocker}): ${counts.reset} reset; ${counts.skipped} protected report(s) skipped; ${counts.concurrentlyChanged} concurrently changed report(s) skipped`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (refresh) {
|
||||
const counts = await refreshPlaceholderReports(manifest.packets, {
|
||||
read: async (packet) => {
|
||||
const reportPath = join(reportsRoot, packet.id, "report.md");
|
||||
return readSafeReportFile(qaRoot, reportPath, `${packet.id} report path`);
|
||||
},
|
||||
isSafePlaceholder: (content, packet) => {
|
||||
const parsed = parseMetadata(content, packet);
|
||||
return parsed.errors.length === 0 && !reportMetadataHasEvidence(parsed.metadata);
|
||||
},
|
||||
render: renderPacketReport,
|
||||
write: async (packet, content) => {
|
||||
const reportPath = join(reportsRoot, packet.id, "report.md");
|
||||
await writeSafeReportFile(qaRoot, reportPath, content, `${packet.id} report path`);
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`release-v2 QA placeholder refresh: ${counts.refreshed} refreshed; ${counts.skipped} protected report(s) skipped`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const packet of manifest.packets) {
|
||||
const packetDirectory = join(reportsRoot, packet.id);
|
||||
const reportPath = join(packetDirectory, "report.md");
|
||||
await assertSafeReportPath(qaRoot, reportPath, `${packet.id} report path`);
|
||||
await mkdir(packetDirectory, { recursive: true });
|
||||
await assertSafeReportPath(qaRoot, reportPath, `${packet.id} report path`);
|
||||
if (refresh) {
|
||||
try {
|
||||
const parsed = parseMetadata(await readSafeReportFile(qaRoot, reportPath, `${packet.id} report path`), packet);
|
||||
if (parsed.errors.length > 0) throw new Error(parsed.errors.join("; "));
|
||||
const existing = parsed.metadata;
|
||||
const hasExecution =
|
||||
existing.status !== "not-reached" ||
|
||||
Object.values(existing.caseStatuses).some((status) => status !== "not-reached") ||
|
||||
existing.findings.length > 0 ||
|
||||
existing.artifacts.length > 0 ||
|
||||
(existing.widgetChecks ?? []).some((check) => check.status !== "not-reached") ||
|
||||
(existing.performance?.measurements.length ?? 0) > 0 ||
|
||||
(existing.performance?.limitations.length ?? 0) > 0 ||
|
||||
(existing.independentReproductions?.length ?? 0) > 0;
|
||||
if (hasExecution) throw new Error(`${packet.id}: refusing to replace a report containing execution results`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.includes("ENOENT")) throw error;
|
||||
}
|
||||
await writeSafeReportFile(qaRoot, reportPath, renderPacketReport(packet), `${packet.id} report path`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await readSafeReportFile(qaRoot, reportPath, `${packet.id} report path`);
|
||||
} catch (error) {
|
||||
@@ -402,7 +586,7 @@ const coverageTable = (assignments: CoverageAssignment[], labelHeading: string)
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (init || refresh) await ensureReports();
|
||||
if (init || refresh || rolloverResolvedBlocker) await ensureReports();
|
||||
|
||||
const reports: { packet: Packet; metadata: ReportMetadata; metadataErrors: string[] }[] = [];
|
||||
for (const packet of manifest.packets) {
|
||||
@@ -627,4 +811,5 @@ const main = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null;
|
||||
if (entrypoint === import.meta.url) await main();
|
||||
@@ -0,0 +1,281 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveCheckoutCandidateSha } from "./provenance.mts";
|
||||
import {
|
||||
hostRuntimeLimitation,
|
||||
refreshPlaceholderReports,
|
||||
renderPacketReport,
|
||||
reportContentIsOnlyBlockedBy,
|
||||
reportMetadataHasEvidence,
|
||||
rolloverResolvedBlockerReports,
|
||||
} from "./report.mts";
|
||||
import { createRuntimeExecutionContract } from "./runtime.mts";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
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", () => {
|
||||
const runtimeExecutionContract = createRuntimeExecutionContract();
|
||||
|
||||
expect(hostRuntimeLimitation).toContain(`the ${runtimeExecutionContract.bundler} bundler`);
|
||||
expect(hostRuntimeLimitation).toContain(
|
||||
`${runtimeExecutionContract.watcher.watchpackPollingIntervalMs}ms Watchpack polling`,
|
||||
);
|
||||
expect(hostRuntimeLimitation).not.toContain("Turbopack");
|
||||
});
|
||||
});
|
||||
|
||||
interface TestPacket {
|
||||
id: string;
|
||||
wave: string;
|
||||
title: string;
|
||||
prRefs: number[];
|
||||
personas: string[];
|
||||
boards: string[];
|
||||
profiles: string[];
|
||||
viewports: string[];
|
||||
zooms: number[];
|
||||
inputs: string[];
|
||||
widgetKinds?: string[];
|
||||
cases: string[];
|
||||
}
|
||||
|
||||
const coverageManifest = JSON.parse(
|
||||
await readFile(path.join(repoRoot, "qa/release-v2/coverage-manifest.json"), "utf8"),
|
||||
) as { packets: TestPacket[] };
|
||||
const packet = (packetId: string): TestPacket => {
|
||||
const result = coverageManifest.packets.find((entry) => entry.id === packetId);
|
||||
if (!result) throw new Error(`Missing test packet ${packetId}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
const blockedByResolvedFinding = (testPacket: TestPacket): string => {
|
||||
const placeholder = renderPacketReport(testPacket);
|
||||
const rawMetadata = placeholder.match(/<!-- release-v2-qa-report\n([\s\S]*?)\n-->/)?.[1];
|
||||
if (!rawMetadata) throw new Error(`Missing generated metadata for ${testPacket.id}`);
|
||||
const metadata = JSON.parse(rawMetadata) as Record<string, unknown> & {
|
||||
status: string;
|
||||
caseStatuses: Record<string, string>;
|
||||
execution: Record<string, unknown>;
|
||||
findings: unknown[];
|
||||
artifacts: string[];
|
||||
widgetChecks: { status: string }[];
|
||||
performance: { measurements: unknown[]; limitations: string[] };
|
||||
independentReproductions: unknown[];
|
||||
};
|
||||
metadata.status = "blocked";
|
||||
metadata.caseStatuses = Object.fromEntries(Object.keys(metadata.caseStatuses).map((caseId) => [caseId, "blocked"]));
|
||||
metadata.widgetChecks = metadata.widgetChecks.map((check) => ({ ...check, status: "blocked" }));
|
||||
metadata.execution = {
|
||||
...metadata.execution,
|
||||
url: "http://127.0.0.1:34401",
|
||||
actualPort: 34401,
|
||||
runtimeProfile: "main-writable",
|
||||
runtimeFlags: ["DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
|
||||
persona: testPacket.personas[0] ?? "Avery Admin",
|
||||
sessionId: `qa-v2-${testPacket.id}`,
|
||||
timestamp: "2026-08-31T15:00:00.000Z",
|
||||
viewport: testPacket.viewports[0] ?? "desktop-1440",
|
||||
input: testPacket.inputs[0] ?? "mouse",
|
||||
zoom: testPacket.zooms[0] ?? 100,
|
||||
};
|
||||
metadata.findings = [
|
||||
{
|
||||
fingerprint: blockerFingerprint,
|
||||
severity: "P1",
|
||||
title: "Locale rewrite redirect loop",
|
||||
evidence: [`/tmp/${testPacket.id}/auth-loop.png`],
|
||||
},
|
||||
];
|
||||
metadata.artifacts = [`/tmp/${testPacket.id}/auth-loop.png`];
|
||||
metadata.performance.limitations = ["The application never rendered because of the resolved auth blocker."];
|
||||
metadata.independentReproductions = [
|
||||
{
|
||||
findingFingerprint: blockerFingerprint,
|
||||
agentId: "preflight-01",
|
||||
outcome: "reproduced",
|
||||
evidence: [],
|
||||
notes: "Auth-only reproduction.",
|
||||
},
|
||||
];
|
||||
return placeholder.replace(rawMetadata, JSON.stringify(metadata, null, 2));
|
||||
};
|
||||
|
||||
const untouchedMetadata = () => ({
|
||||
packetId: "preflight-01",
|
||||
status: "not-reached" as const,
|
||||
caseStatuses: { "PF01-FIXTURE-STATE": "not-reached" as const },
|
||||
execution: {
|
||||
candidateSha: "f57a660088d6777c86aca22977354fa8b810e2be",
|
||||
url: null,
|
||||
actualPort: null,
|
||||
runtimeProfile: null,
|
||||
runtimeFlags: [],
|
||||
persona: null,
|
||||
sessionId: null,
|
||||
timestamp: null,
|
||||
viewport: null,
|
||||
input: null,
|
||||
zoom: null,
|
||||
},
|
||||
findings: [],
|
||||
artifacts: [],
|
||||
widgetChecks: [],
|
||||
performance: { measurements: [], limitations: [] },
|
||||
independentReproductions: [],
|
||||
notes: "Not executed.",
|
||||
});
|
||||
|
||||
describe("release-v2 QA report placeholder refresh", () => {
|
||||
it("preserves an executed report before refreshing a later untouched placeholder", async () => {
|
||||
const reports = new Map([
|
||||
["preflight-01", "executed report evidence"],
|
||||
["board-01", "untouched placeholder from the previous candidate"],
|
||||
]);
|
||||
|
||||
const counts = await refreshPlaceholderReports(["preflight-01", "board-01"], {
|
||||
read: async (packetId) => reports.get(packetId) ?? "",
|
||||
isSafePlaceholder: (content) => content.startsWith("untouched placeholder"),
|
||||
render: (packetId) => `refreshed placeholder for ${packetId}`,
|
||||
write: async (packetId, content) => {
|
||||
reports.set(packetId, content);
|
||||
},
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ refreshed: 1, skipped: 1 });
|
||||
expect(reports.get("preflight-01")).toBe("executed report evidence");
|
||||
expect(reports.get("board-01")).toBe("refreshed placeholder for board-01");
|
||||
});
|
||||
|
||||
it("treats execution metadata as evidence even before a case status changes", () => {
|
||||
const metadata = untouchedMetadata();
|
||||
metadata.execution.url = "http://127.0.0.1:34401";
|
||||
|
||||
expect(reportMetadataHasEvidence(metadata)).toBe(true);
|
||||
});
|
||||
|
||||
it("protects every evidence-bearing report while allowing a candidate-only placeholder", () => {
|
||||
expect(reportMetadataHasEvidence(untouchedMetadata())).toBe(false);
|
||||
|
||||
const evidenceReports = [
|
||||
() => ({ ...untouchedMetadata(), status: "passed" as const }),
|
||||
() => ({ ...untouchedMetadata(), caseStatuses: { "PF01-FIXTURE-STATE": "blocked" as const } }),
|
||||
() => ({
|
||||
...untouchedMetadata(),
|
||||
widgetChecks: [
|
||||
{ widgetKind: "weather", viewport: "320x568", sizeRequirement: "minimum", status: "passed" as const },
|
||||
],
|
||||
}),
|
||||
() => ({
|
||||
...untouchedMetadata(),
|
||||
findings: [{ severity: "P3" as const, title: "Observed defect" }],
|
||||
}),
|
||||
() => ({ ...untouchedMetadata(), artifacts: ["/tmp/evidence.png"] }),
|
||||
() => ({
|
||||
...untouchedMetadata(),
|
||||
performance: {
|
||||
measurements: [
|
||||
{
|
||||
name: "render",
|
||||
value: 1,
|
||||
unit: "ms",
|
||||
threshold: "under 10 ms",
|
||||
status: "passed" as const,
|
||||
evidence: [],
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
},
|
||||
}),
|
||||
() => ({ ...untouchedMetadata(), performance: { measurements: [], limitations: ["Host limitation"] } }),
|
||||
() => ({
|
||||
...untouchedMetadata(),
|
||||
independentReproductions: [
|
||||
{
|
||||
findingFingerprint: "finding",
|
||||
agentId: "reproducer",
|
||||
outcome: "reproduced" as const,
|
||||
evidence: [],
|
||||
notes: "Confirmed.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
for (const evidenceReport of evidenceReports) expect(reportMetadataHasEvidence(evidenceReport())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release-v2 QA resolved-blocker rollover", () => {
|
||||
it("resets actual board and widget packet shapes while preserving an executed preflight", async () => {
|
||||
const preflightPacket = packet("preflight-01");
|
||||
const boardPacket = packet("board-01");
|
||||
const widgetPacket = packet("widgets-01");
|
||||
const executedPreflight = renderPacketReport(preflightPacket).replace(
|
||||
'"status": "not-reached"',
|
||||
'"status": "passed"',
|
||||
);
|
||||
const reports = new Map<string, { content: string; revision: number }>([
|
||||
[preflightPacket.id, { content: executedPreflight, revision: 1 }],
|
||||
[boardPacket.id, { content: blockedByResolvedFinding(boardPacket), revision: 1 }],
|
||||
[widgetPacket.id, { content: blockedByResolvedFinding(widgetPacket), revision: 1 }],
|
||||
]);
|
||||
|
||||
const counts = await rolloverResolvedBlockerReports([preflightPacket, boardPacket, widgetPacket], {
|
||||
read: async (entry) => {
|
||||
const report = reports.get(entry.id);
|
||||
if (!report) throw new Error(`Missing report ${entry.id}`);
|
||||
return { ...report };
|
||||
},
|
||||
isEligible: (content, entry) => reportContentIsOnlyBlockedBy(content, entry, blockerFingerprint),
|
||||
isUnchanged: async (entry, snapshot) => {
|
||||
const current = reports.get(entry.id);
|
||||
return current?.content === snapshot.content && current.revision === snapshot.revision;
|
||||
},
|
||||
render: renderPacketReport,
|
||||
write: async (entry, content) => {
|
||||
reports.set(entry.id, { content, revision: 2 });
|
||||
},
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ reset: 2, skipped: 1, concurrentlyChanged: 0 });
|
||||
expect(reports.get(preflightPacket.id)?.content).toBe(executedPreflight);
|
||||
for (const packetId of [boardPacket.id, widgetPacket.id]) {
|
||||
const refreshed = reports.get(packetId)?.content ?? "";
|
||||
expect(refreshed).toContain(`"candidateSha": "${candidateSha}"`);
|
||||
expect(refreshed).toContain(`| Candidate SHA | ${candidateSha} |`);
|
||||
expect(refreshed).toContain("| Status | not-reached |");
|
||||
expect(refreshed).not.toContain(blockerFingerprint);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips a report changed by an agent after eligibility was checked", async () => {
|
||||
const boardPacket = packet("board-01");
|
||||
const initial = blockedByResolvedFinding(boardPacket);
|
||||
const agentUpdate = `${initial}\nAgent completed new evidence.\n`;
|
||||
const reports = new Map([[boardPacket.id, { content: initial, revision: 1 }]]);
|
||||
|
||||
const counts = await rolloverResolvedBlockerReports([boardPacket], {
|
||||
read: async () => ({ content: initial, revision: 1 }),
|
||||
isEligible: (content, entry) => reportContentIsOnlyBlockedBy(content, entry, blockerFingerprint),
|
||||
isUnchanged: async () => {
|
||||
reports.set(boardPacket.id, { content: agentUpdate, revision: 2 });
|
||||
return false;
|
||||
},
|
||||
render: renderPacketReport,
|
||||
write: async (entry, content) => {
|
||||
reports.set(entry.id, { content, revision: 3 });
|
||||
},
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ reset: 0, skipped: 0, concurrentlyChanged: 1 });
|
||||
expect(reports.get(boardPacket.id)?.content).toBe(agentUpdate);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ 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 runtimeBundler = "webpack" as const;
|
||||
const profiles = ["main-writable", "main-readonly", "onboarding-fresh", "degraded"] as const;
|
||||
|
||||
type Profile = (typeof profiles)[number];
|
||||
@@ -30,6 +31,53 @@ export const assertRuntimeCommandPlatform = (command: RuntimeCommand, platform:
|
||||
assertSupportedRuntimePlatform(platform);
|
||||
};
|
||||
|
||||
interface RuntimeExecutionContract {
|
||||
bundler: typeof runtimeBundler;
|
||||
watcher: { watchpackPollingIntervalMs: number };
|
||||
}
|
||||
|
||||
export const createRuntimeExecutionContract = (): RuntimeExecutionContract => ({
|
||||
bundler: runtimeBundler,
|
||||
watcher: { watchpackPollingIntervalMs },
|
||||
});
|
||||
|
||||
export const validateRuntimeExecutionContract = (value: {
|
||||
bundler?: unknown;
|
||||
watcher?: { watchpackPollingIntervalMs?: unknown };
|
||||
}): string[] => {
|
||||
const contractErrors: string[] = [];
|
||||
if (value.bundler !== runtimeBundler) contractErrors.push("bundler must be webpack");
|
||||
if (value.watcher?.watchpackPollingIntervalMs !== watchpackPollingIntervalMs) {
|
||||
contractErrors.push("Watchpack polling interval must be 1000ms");
|
||||
}
|
||||
return contractErrors;
|
||||
};
|
||||
|
||||
export const createAppLaunchCommand = (
|
||||
appPort: number,
|
||||
options: { platform?: NodeJS.Platform; prlimitAvailable?: boolean } = {},
|
||||
) => {
|
||||
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),
|
||||
];
|
||||
if (platform === "linux" && prlimitAvailable) {
|
||||
return {
|
||||
command: "/usr/bin/prlimit",
|
||||
arguments: ["--nofile=65536:1048576", "--", "pnpm", ...pnpmArguments],
|
||||
};
|
||||
}
|
||||
return { command: "pnpm", arguments: pnpmArguments };
|
||||
};
|
||||
|
||||
interface Options {
|
||||
command: RuntimeCommand;
|
||||
slot: number;
|
||||
@@ -59,6 +107,7 @@ interface RuntimeManifest {
|
||||
fixtureUrl: string;
|
||||
ports: { app: number; fixture: number; redis: number };
|
||||
flags: { demoMode: boolean; demoReadOnly: boolean; unsafeMockIntegration: boolean };
|
||||
bundler: typeof runtimeBundler;
|
||||
watcher: { watchpackPollingIntervalMs: number };
|
||||
processes: {
|
||||
app: { pid: number; logPath: string };
|
||||
@@ -582,7 +631,6 @@ 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");
|
||||
|
||||
let appCommand = "pnpm";
|
||||
const appEnvironment = {
|
||||
...sanitizeAppEnvironment(environment),
|
||||
WATCHPACK_POLLING: String(watchpackPollingIntervalMs),
|
||||
@@ -592,18 +640,12 @@ const startWithLease = async (options: Options) => {
|
||||
async (attemptPort) => {
|
||||
appPort = attemptPort;
|
||||
url = `http://127.0.0.1:${appPort}`;
|
||||
let appArguments = ["--filter", "@homarr/nextjs", "dev", "--hostname", "127.0.0.1", "--port", String(appPort)];
|
||||
if (process.platform === "linux" && existsSync("/usr/bin/prlimit")) {
|
||||
appCommand = "/usr/bin/prlimit";
|
||||
appArguments = ["--nofile=65536:1048576", "--", "pnpm", ...appArguments];
|
||||
} else {
|
||||
appCommand = "pnpm";
|
||||
}
|
||||
const appLaunch = createAppLaunchCommand(appPort);
|
||||
const logOffset = await stat(appLogPath)
|
||||
.then((stats) => stats.size)
|
||||
.catch(() => 0);
|
||||
await assertSafeContainedPath(options.runRoot, appLogPath, "QA app log");
|
||||
const spawned = spawnLogged(appCommand, appArguments, appLogPath, {
|
||||
const spawned = spawnLogged(appLaunch.command, appLaunch.arguments, appLogPath, {
|
||||
...appEnvironment,
|
||||
HOMARR_WEBSITE_URL: url,
|
||||
});
|
||||
@@ -641,7 +683,7 @@ const startWithLease = async (options: Options) => {
|
||||
fixtureUrl: fixtureReady.url,
|
||||
ports: { app: appPort, fixture: fixtureReady.port, redis: redisPort },
|
||||
flags,
|
||||
watcher: { watchpackPollingIntervalMs },
|
||||
...createRuntimeExecutionContract(),
|
||||
processes: {
|
||||
app: { pid: appPid, logPath: appLogPath },
|
||||
fixture: { pid: fixturePid, logPath: fixtureLogPath },
|
||||
|
||||
@@ -4,7 +4,14 @@ import { createServer } from "node:net";
|
||||
import type { Server } from "node:net";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { assertRuntimeCommandPlatform, launchWithPortRetry, reserveLoopbackPort } from "./runtime.mts";
|
||||
import {
|
||||
assertRuntimeCommandPlatform,
|
||||
createAppLaunchCommand,
|
||||
createRuntimeExecutionContract,
|
||||
launchWithPortRetry,
|
||||
reserveLoopbackPort,
|
||||
validateRuntimeExecutionContract,
|
||||
} from "./runtime.mts";
|
||||
|
||||
const openServers: Server[] = [];
|
||||
|
||||
@@ -79,3 +86,46 @@ describe("release-v2 QA runtime platform boundary", () => {
|
||||
expect(() => assertRuntimeCommandPlatform(command, "linux")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
expect(launch).toEqual({
|
||||
command: "/usr/bin/prlimit",
|
||||
arguments: [
|
||||
"--nofile=65536:1048576",
|
||||
"--",
|
||||
"pnpm",
|
||||
"--filter",
|
||||
"@homarr/nextjs",
|
||||
"dev",
|
||||
"--webpack",
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"31337",
|
||||
],
|
||||
});
|
||||
expect(launchWithoutPrlimit.command).toBe("pnpm");
|
||||
expect(launchWithoutPrlimit.arguments).toContain("--webpack");
|
||||
});
|
||||
|
||||
test("records and validates the deterministic bundler and watcher settings", () => {
|
||||
const execution = createRuntimeExecutionContract();
|
||||
|
||||
expect(execution).toEqual({
|
||||
bundler: "webpack",
|
||||
watcher: { watchpackPollingIntervalMs: 1_000 },
|
||||
});
|
||||
expect(validateRuntimeExecutionContract(execution)).toEqual([]);
|
||||
expect(
|
||||
validateRuntimeExecutionContract({
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +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 { assertSafeRunRoot, isPathWithin, validateFixtureUrl } from "./safety.mts";
|
||||
|
||||
@@ -117,6 +118,7 @@ interface RuntimeManifest {
|
||||
fixtureUrl: string;
|
||||
ports: { app: number; fixture: number; redis: number };
|
||||
flags: { demoMode: boolean; demoReadOnly: boolean; unsafeMockIntegration: boolean };
|
||||
bundler: string;
|
||||
watcher: { watchpackPollingIntervalMs: number };
|
||||
processes: {
|
||||
app: { pid: number; logPath: string };
|
||||
@@ -629,8 +631,8 @@ const validateRuntimeManifest = async (
|
||||
if (JSON.stringify(runtime.flags) !== JSON.stringify(expectedFlags)) {
|
||||
errors.push(`${label}: runtime flags do not match profile ${runtime.profile}`);
|
||||
}
|
||||
if (runtime.watcher?.watchpackPollingIntervalMs !== 1_000) {
|
||||
errors.push(`${label}: Watchpack polling interval must be 1000ms`);
|
||||
for (const executionError of validateRuntimeExecutionContract(runtime)) {
|
||||
errors.push(`${label}: ${executionError}`);
|
||||
}
|
||||
if (resolve(runtime.repoRoot) !== repoRoot) errors.push(`${label}: repoRoot does not match this checkout`);
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user