test: add release v2 browser QA campaign

This commit is contained in:
ajnart committed 2026-09-01 01:03:27 +02:00
1 parent f57a660088
commit ec95d18245
83 files changed
+20922 -19

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { readFile, mkdir, open, rename, rm } from "node:fs/promises";
import path from "node:path";
import { validateLoopbackHost } from "./safety.mts";
const DEFAULT_HOST = "127.0.0.1";
const MAX_BODY_BYTES = 2 * 1024 * 1024;
const MAX_SLOW_MS = 30_000;
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const videoPath = path.resolve(
import.meta.dirname,
"../../apps/docs/src/components/pages/home/drag-and-drop/showcase-dark.mp4",
);
const createWav = () => {
const sampleRate = 8_000;
const sampleCount = sampleRate / 4;
const dataLength = sampleCount * 2;
const buffer = Buffer.alloc(44 + dataLength);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(36 + dataLength, 4);
buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(dataLength, 40);
for (let index = 0; index < sampleCount; index += 1) {
const value = Math.sin((2 * Math.PI * 440 * index) / sampleRate) * 0.2;
buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * 2);
}
return buffer;
};
const wav = createWav();
const customWidgetPayload = {
title: "Release v2 QA fixture",
status: "ok",
items: [
{ id: "alpha", label: "Alpha", value: 42 },
{ id: "beta", label: "Beta", value: 7 },
],
};
const parseArgs = () => {
const args = process.argv.slice(2).filter((argument) => argument !== "--");
let host = DEFAULT_HOST;
let port = 0;
let readyFile: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
const value = args[index + 1];
if (argument === "--host" && value) {
host = value;
index += 1;
continue;
}
if (argument === "--port" && value) {
port = Number(value);
index += 1;
continue;
}
if (argument === "--ready-file" && value) {
readyFile = path.resolve(value);
index += 1;
continue;
}
throw new Error(`Unknown or incomplete argument: ${argument ?? "<missing>"}`);
}
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
throw new Error("--port must be an integer between 0 and 65535");
}
return { host: validateLoopbackHost(host), port, readyFile };
};
const setCommonHeaders = (response: ServerResponse) => {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, X-QA-Fixture");
response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-QA-Fixture", "release-v2");
};
const send = (response: ServerResponse, status: number, contentType: string, body: string | Buffer) => {
setCommonHeaders(response);
response.writeHead(status, { "Content-Type": contentType });
response.end(body);
};
const sendJson = (response: ServerResponse, status: number, value: unknown) => {
send(response, status, "application/json; charset=utf-8", `${JSON.stringify(value)}\n`);
};
const readBody = async (request: IncomingMessage) => {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_BODY_BYTES) throw new Error("UPLOAD_TOO_LARGE");
chunks.push(buffer);
}
return Buffer.concat(chunks);
};
const handleRequest = async (request: IncomingMessage, response: ServerResponse) => {
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? DEFAULT_HOST}`);
const method = request.method ?? "GET";
if (method === "OPTIONS") {
setCommonHeaders(response);
response.writeHead(204);
response.end();
return;
}
if (url.pathname === "/health") {
sendJson(response, 200, { status: "ok", fixture: "release-v2-qa" });
return;
}
if (url.pathname === "/json") {
sendJson(response, 200, {
id: "qa-json-001",
name: "Release v2 deterministic JSON",
active: true,
count: 3,
tags: ["homarr", "release-v2", "qa"],
nested: { score: 99, nullable: null },
});
return;
}
if (url.pathname === "/api/qa/custom-widget") {
sendJson(response, 200, customWidgetPayload);
return;
}
if (url.pathname === "/rss") {
send(
response,
200,
"application/rss+xml; charset=utf-8",
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>Release v2 QA</title><link>https://example.invalid/qa</link><description>Deterministic feed</description><item><guid>qa-entry-1</guid><title>Alpha release note</title><link>https://example.invalid/qa/alpha</link><pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate><description>Stable fixture entry</description></item><item><guid>qa-entry-2</guid><title>Beta release note</title><link>https://example.invalid/qa/beta</link><pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate><description>Second stable fixture entry</description></item></channel></rss>\n`,
);
return;
}
if (url.pathname === "/media/image.png") {
send(response, 200, "image/png", png);
return;
}
if (url.pathname === "/media/image.svg") {
send(
response,
200,
"image/svg+xml; charset=utf-8",
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180"><rect width="320" height="180" fill="#111827"/><circle cx="72" cy="90" r="40" fill="#38bdf8"/><text x="132" y="98" fill="#f8fafc" font-family="sans-serif" font-size="22">Release v2 QA</text></svg>\n',
);
return;
}
if (url.pathname === "/media/audio.wav") {
send(response, 200, "audio/wav", wav);
return;
}
if (url.pathname === "/media/video.mp4") {
const video = await readFile(videoPath);
const range = request.headers.range;
setCommonHeaders(response);
response.setHeader("Accept-Ranges", "bytes");
if (!range) {
response.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": video.length });
response.end(method === "HEAD" ? undefined : video);
return;
}
const match = /^bytes=(\d+)-(\d*)$/.exec(range);
if (!match) {
response.writeHead(416, { "Content-Range": `bytes */${video.length}` });
response.end();
return;
}
const start = Number(match[1]);
const requestedEnd = match[2] ? Number(match[2]) : video.length - 1;
const end = Math.min(requestedEnd, video.length - 1);
if (start > end || start >= video.length) {
response.writeHead(416, { "Content-Range": `bytes */${video.length}` });
response.end();
return;
}
const body = video.subarray(start, end + 1);
response.writeHead(206, {
"Content-Type": "video/mp4",
"Content-Length": body.length,
"Content-Range": `bytes ${start}-${end}/${video.length}`,
});
response.end(method === "HEAD" ? undefined : body);
return;
}
if (url.pathname === "/iframe") {
send(
response,
200,
"text/html; charset=utf-8",
'<!doctype html><html><head><meta charset="utf-8"><title>Release v2 QA iframe</title></head><body><main data-qa-fixture="iframe"><h1>Deterministic iframe</h1><p>release-v2-qa</p></main></body></html>\n',
);
return;
}
if (url.pathname === "/api/qa/download/sample.txt") {
setCommonHeaders(response);
response.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": 'attachment; filename="release-v2-qa-sample.txt"',
});
response.end("Release v2 QA deterministic download\nline=2\n");
return;
}
if (url.pathname === "/upload") {
if (method !== "POST" && method !== "PUT") {
sendJson(response, 405, { error: "METHOD_NOT_ALLOWED", allowed: ["POST", "PUT"] });
return;
}
try {
const body = await readBody(request);
sendJson(response, 200, {
uploaded: true,
method,
bytes: body.length,
contentType: request.headers["content-type"] ?? null,
sha256: createHash("sha256").update(body).digest("hex"),
});
} catch (error) {
if (error instanceof Error && error.message === "UPLOAD_TOO_LARGE") {
sendJson(response, 413, { error: "UPLOAD_TOO_LARGE", maxBytes: MAX_BODY_BYTES });
return;
}
throw error;
}
return;
}
if (url.pathname === "/empty") {
setCommonHeaders(response);
response.writeHead(204);
response.end();
return;
}
if (url.pathname === "/malformed" || url.pathname === "/malformed/json") {
send(response, 200, "application/json; charset=utf-8", '{"status":"broken","items":[1,2,}\n');
return;
}
if (url.pathname === "/slow") {
const requestedMs = Number(url.searchParams.get("ms") ?? "1500");
const delayMs = Number.isFinite(requestedMs) ? Math.max(0, Math.min(Math.trunc(requestedMs), MAX_SLOW_MS)) : 1500;
await new Promise((resolve) => setTimeout(resolve, delayMs));
sendJson(response, 200, { status: "ok", delayedMs: delayMs });
return;
}
if (url.pathname === "/error" || url.pathname.startsWith("/error/")) {
const pathStatus = url.pathname.split("/")[2];
const requestedStatus = Number(url.searchParams.get("status") ?? pathStatus ?? "503");
const status =
Number.isInteger(requestedStatus) && requestedStatus >= 400 && requestedStatus <= 599 ? requestedStatus : 503;
sendJson(response, status, { error: "QA_CONFIGURED_HTTP_ERROR", status });
return;
}
sendJson(response, 404, { error: "NOT_FOUND", path: url.pathname });
};
const writeJsonAtomic = async (filePath: string, value: unknown) => {
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
const handle = await open(
temporaryPath,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
0o600,
);
try {
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true });
throw error;
}
};
const main = async () => {
const options = parseArgs();
const server = createServer((request, response) => {
void handleRequest(request, response).catch((error: unknown) => {
console.error(error);
if (!response.headersSent) sendJson(response, 500, { error: "FIXTURE_INTERNAL_ERROR" });
else response.destroy();
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(options.port, options.host, resolve);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("Fixture server did not expose a TCP address");
const urlHost = options.host.includes(":") ? `[${options.host}]` : options.host;
const url = `http://${urlHost}:${address.port}`;
const ready = { schemaVersion: 1, pid: process.pid, host: options.host, port: address.port, url };
if (options.readyFile) await writeJsonAtomic(options.readyFile, ready);
process.stdout.write(`${JSON.stringify(ready)}\n`);
const shutdown = () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5_000).unref();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
};
await main();
+152
View File
@@ -0,0 +1,152 @@
import type { LayoutRole } from "../../packages/definitions/index.ts";
import { getBoardLaneColumnCount, getRootSectionLane } from "../../packages/definitions/index.ts";
interface GeometryLayout {
id: string;
boardId: string;
columnCount: number;
leftGutterColumnCount: number;
rightGutterColumnCount: number;
role: LayoutRole;
}
interface GeometrySection {
id: string;
boardId: string;
kind: "empty" | "container";
xOffset: number | null;
}
interface GeometryPlacement {
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
}
interface GeometrySectionLayout extends GeometryPlacement {
parentSectionId: string | null;
}
interface GeometryItemLayout extends GeometryPlacement {
itemId: string;
boardId: string;
}
export interface FixtureGeometry {
layouts: GeometryLayout[];
sections: GeometrySection[];
sectionLayouts: GeometrySectionLayout[];
itemLayouts: GeometryItemLayout[];
}
const placementIsValid = (placement: GeometryPlacement) =>
Number.isInteger(placement.xOffset) &&
Number.isInteger(placement.yOffset) &&
Number.isInteger(placement.width) &&
Number.isInteger(placement.height) &&
placement.xOffset >= 0 &&
placement.yOffset >= 0 &&
placement.width >= 1 &&
placement.height >= 1;
export const findFixtureGeometryErrors = (geometry: FixtureGeometry) => {
const errors: string[] = [];
const layoutsById = new Map(geometry.layouts.map((layout) => [layout.id, layout]));
const sectionsById = new Map(geometry.sections.map((section) => [section.id, section]));
const sectionLayoutsByKey = new Map<string, GeometrySectionLayout>();
for (const sectionLayout of geometry.sectionLayouts) {
const key = `${sectionLayout.layoutId}\0${sectionLayout.sectionId}`;
if (sectionLayoutsByKey.has(key)) {
errors.push(`container ${sectionLayout.sectionId} has duplicate placement in layout ${sectionLayout.layoutId}`);
continue;
}
sectionLayoutsByKey.set(key, sectionLayout);
}
const parentCapacity = (
layout: GeometryLayout,
parentSection: GeometrySection,
): { columns: number; kind: "lane" | "container" } | undefined => {
if (parentSection.kind === "empty") {
return {
columns: getBoardLaneColumnCount(layout, getRootSectionLane(parentSection.xOffset)),
kind: "lane",
};
}
const parentLayout = sectionLayoutsByKey.get(`${layout.id}\0${parentSection.id}`);
if (!parentLayout) return undefined;
return { columns: parentLayout.width, kind: "container" };
};
for (const sectionLayout of geometry.sectionLayouts) {
const layout = layoutsById.get(sectionLayout.layoutId);
const section = sectionsById.get(sectionLayout.sectionId);
const parent = sectionLayout.parentSectionId ? sectionsById.get(sectionLayout.parentSectionId) : undefined;
if (!layout || !section || !parent) {
errors.push(
`container ${sectionLayout.sectionId} in layout ${sectionLayout.layoutId} has unknown geometry references`,
);
continue;
}
if (section.kind !== "container" || section.boardId !== layout.boardId || parent.boardId !== layout.boardId) {
errors.push(`container ${section.id} in layout ${layout.id} crosses a board or root boundary`);
continue;
}
if (!placementIsValid(sectionLayout)) {
errors.push(`container ${section.id} in layout ${layout.id} has an invalid placement`);
continue;
}
const capacity = parentCapacity(layout, parent);
if (!capacity) {
errors.push(`container ${section.id} in layout ${layout.id} has an unplaced parent container`);
} else if (sectionLayout.xOffset + sectionLayout.width > capacity.columns) {
errors.push(
`container ${section.id} in layout ${layout.id} exceeds its ${capacity.columns}-column parent ${capacity.kind}`,
);
}
const visited = new Set<string>();
let currentSectionId: string | null = section.id;
while (currentSectionId) {
if (visited.has(currentSectionId)) {
errors.push(`container cycle detected in layout ${layout.id} at ${currentSectionId}`);
break;
}
visited.add(currentSectionId);
const current = sectionsById.get(currentSectionId);
if (!current || current.kind === "empty") break;
currentSectionId = sectionLayoutsByKey.get(`${layout.id}\0${currentSectionId}`)?.parentSectionId ?? null;
}
}
for (const itemLayout of geometry.itemLayouts) {
const layout = layoutsById.get(itemLayout.layoutId);
const parent = sectionsById.get(itemLayout.sectionId);
if (!layout || !parent || itemLayout.boardId !== layout.boardId || parent.boardId !== layout.boardId) {
errors.push(`item ${itemLayout.itemId} in layout ${itemLayout.layoutId} crosses a board or section boundary`);
continue;
}
if (!placementIsValid(itemLayout)) {
errors.push(`item ${itemLayout.itemId} in layout ${layout.id} has an invalid placement`);
continue;
}
const capacity = parentCapacity(layout, parent);
if (!capacity) {
errors.push(`item ${itemLayout.itemId} in layout ${layout.id} has an unplaced parent container`);
continue;
}
if (itemLayout.xOffset + itemLayout.width > capacity.columns) {
errors.push(
`item ${itemLayout.itemId} in layout ${layout.id} exceeds its ${capacity.columns}-column parent ${capacity.kind}`,
);
}
}
return errors;
};
+119
View File
@@ -0,0 +1,119 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import type { FixtureGeometry } from "./geometry.mts";
import { findFixtureGeometryErrors } from "./geometry.mts";
const baseGeometry = (): FixtureGeometry => ({
layouts: [
{
id: "layout-mobile",
boardId: "board",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
role: "mobile",
},
],
sections: [
{ id: "root", boardId: "board", kind: "empty", xOffset: 0 },
{ id: "level-1", boardId: "board", kind: "container", xOffset: null },
{ id: "level-2", boardId: "board", kind: "container", xOffset: null },
{ id: "level-3", boardId: "board", kind: "container", xOffset: null },
],
sectionLayouts: [
{
sectionId: "level-1",
layoutId: "layout-mobile",
parentSectionId: "root",
xOffset: 0,
yOffset: 0,
width: 3,
height: 6,
},
{
sectionId: "level-2",
layoutId: "layout-mobile",
parentSectionId: "level-1",
xOffset: 0,
yOffset: 0,
width: 2,
height: 5,
},
{
sectionId: "level-3",
layoutId: "layout-mobile",
parentSectionId: "level-2",
xOffset: 0,
yOffset: 0,
width: 1,
height: 4,
},
],
itemLayouts: [
{
itemId: "nested-item",
boardId: "board",
sectionId: "level-3",
layoutId: "layout-mobile",
xOffset: 0,
yOffset: 0,
width: 1,
height: 2,
},
],
});
describe("release-v2 QA fixture geometry", () => {
test("rejects an item that fits total columns but overflows the gutter-reduced main lane", () => {
const geometry = baseGeometry();
geometry.layouts[0] = {
id: "layout-mobile",
boardId: "board",
role: "base",
columnCount: 12,
leftGutterColumnCount: 2,
rightGutterColumnCount: 2,
};
geometry.sections = [{ id: "root", boardId: "board", kind: "empty", xOffset: 0 }];
geometry.sectionLayouts = [];
geometry.itemLayouts = [
{
itemId: "gutter-overflow",
boardId: "board",
sectionId: "root",
layoutId: "layout-mobile",
xOffset: 0,
yOffset: 0,
width: 9,
height: 1,
},
];
expect(findFixtureGeometryErrors(geometry)).toContain(
"item gutter-overflow in layout layout-mobile exceeds its 8-column parent lane",
);
});
test("accepts valid three-level mobile nesting and rejects a child wider than its container", () => {
const geometry = baseGeometry();
expect(findFixtureGeometryErrors(geometry)).toEqual([]);
const nestedItem = geometry.itemLayouts.at(0);
if (!nestedItem) throw new Error("Missing nested fixture item");
nestedItem.width = 2;
expect(findFixtureGeometryErrors(geometry)).toContain(
"item nested-item in layout layout-mobile exceeds its 1-column parent container",
);
});
test("rejects recursive container parent cycles", () => {
const geometry = baseGeometry();
const firstSection = geometry.sectionLayouts.at(0);
if (!firstSection) throw new Error("Missing nested fixture section");
firstSection.parentSectionId = "level-3";
expect(findFixtureGeometryErrors(geometry).some((error) => error.includes("container cycle"))).toBe(true);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { spawnSync } from "node:child_process";
const fullShaPattern = /^[0-9a-f]{40}$/iu;
export const releaseV2QaProfiles = ["main-writable", "main-readonly", "onboarding-fresh", "degraded"] as const;
export type ReleaseV2QaProfile = (typeof releaseV2QaProfiles)[number];
export interface ReleaseV2QaProfileFlags {
demoMode: boolean;
demoReadOnly: boolean;
unsafeMockIntegration: boolean;
}
const expectedProfileFlags: Record<ReleaseV2QaProfile, ReleaseV2QaProfileFlags> = {
"main-writable": { demoMode: true, demoReadOnly: false, unsafeMockIntegration: true },
"main-readonly": { demoMode: true, demoReadOnly: true, unsafeMockIntegration: true },
"onboarding-fresh": { demoMode: false, demoReadOnly: false, unsafeMockIntegration: true },
degraded: { demoMode: true, demoReadOnly: false, unsafeMockIntegration: true },
};
const profileFlagEnvironmentNames = {
demoMode: "DEMO_MODE",
demoReadOnly: "DEMO_READ_ONLY",
unsafeMockIntegration: "UNSAFE_ENABLE_MOCK_INTEGRATION",
} as const satisfies Record<keyof ReleaseV2QaProfileFlags, string>;
const parseStrictBoolean = (value: string | undefined) => {
if (value === "true") return true;
if (value === "false") return false;
return null;
};
export const assertReleaseV2QaProfileEnvironment = (
profile: ReleaseV2QaProfile,
environment: NodeJS.ProcessEnv,
): ReleaseV2QaProfileFlags => {
const expected = expectedProfileFlags[profile];
const invalidFlags: string[] = [];
for (const [flag, environmentName] of Object.entries(profileFlagEnvironmentNames) as Array<
[keyof ReleaseV2QaProfileFlags, string]
>) {
const expectedValue = expected[flag];
const actualValue = parseStrictBoolean(environment[environmentName]);
if (actualValue !== expectedValue) invalidFlags.push(`${environmentName} (expected ${String(expectedValue)})`);
}
if (invalidFlags.length > 0) {
throw new Error(`QA profile '${profile}' has invalid or missing environment flags: ${invalidFlags.join(", ")}`);
}
return { ...expected };
};
export const validateCandidateSha = (candidateSha: string, checkoutSha: string) => {
if (!fullShaPattern.test(candidateSha)) {
throw new Error("QA candidate must be a full 40-character Git SHA");
}
if (!fullShaPattern.test(checkoutSha)) {
throw new Error("The checked-out HEAD did not resolve to a full 40-character Git SHA");
}
if (candidateSha.toLowerCase() !== checkoutSha.toLowerCase()) {
throw new Error("QA candidate does not match the checked-out HEAD");
}
return checkoutSha.toLowerCase();
};
export const resolveCheckoutCandidateSha = (repoRoot: string) => {
const result = spawnSync("git", ["rev-parse", "--verify", "HEAD^{commit}"], {
cwd: repoRoot,
encoding: "utf8",
});
if (result.status !== 0) throw new Error("Could not resolve the checked-out HEAD for QA provenance");
const checkoutSha = result.stdout.trim();
return validateCandidateSha(checkoutSha, checkoutSha);
};
+133
View File
@@ -0,0 +1,133 @@
// @vitest-environment node
import { spawnSync } from "node:child_process";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { afterEach, describe, expect, test } from "vitest";
import { assertReleaseV2QaProfileEnvironment, releaseV2QaProfiles, validateCandidateSha } from "./provenance.mts";
import type { ReleaseV2QaProfile } from "./provenance.mts";
const disposableDirectories: string[] = [];
const repoRoot = path.resolve(import.meta.dirname, "../..");
const profileEnvironmentNames = ["DEMO_MODE", "DEMO_READ_ONLY", "UNSAFE_ENABLE_MOCK_INTEGRATION"] as const;
afterEach(async () => {
await Promise.all(
disposableDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
);
});
const environmentForProfile = (profile: ReleaseV2QaProfile): NodeJS.ProcessEnv => {
const environment: NodeJS.ProcessEnv = {
DEMO_MODE: "true",
DEMO_READ_ONLY: "false",
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
};
if (profile === "main-readonly") environment.DEMO_READ_ONLY = "true";
if (profile === "onboarding-fresh") environment.DEMO_MODE = "false";
return environment;
};
describe("release-v2 QA candidate provenance", () => {
test("accepts only the exact checked-out commit", () => {
const checkoutSha = "a".repeat(40);
expect(validateCandidateSha(checkoutSha.toUpperCase(), checkoutSha)).toBe(checkoutSha);
expect(() => validateCandidateSha("b".repeat(40), checkoutSha)).toThrow("checked-out HEAD");
expect(() => validateCandidateSha("not-a-sha", checkoutSha)).toThrow("full 40-character Git SHA");
});
});
describe("release-v2 QA profile provenance", () => {
test.each(releaseV2QaProfiles)("accepts the exact %s profile matrix", (profile) => {
expect(assertReleaseV2QaProfileEnvironment(profile, environmentForProfile(profile))).toEqual({
demoMode: profile !== "onboarding-fresh",
demoReadOnly: profile === "main-readonly",
unsafeMockIntegration: true,
});
});
test.each(profileEnvironmentNames)("rejects an unset %s flag", (environmentName) => {
const environment = environmentForProfile("main-writable");
delete environment[environmentName];
expect(() => assertReleaseV2QaProfileEnvironment("main-writable", environment)).toThrow(
new RegExp(`${environmentName} \\(expected (?:true|false)\\)`, "u"),
);
});
test.each(["1", "0", "TRUE", "False", "yes", " true ", "", "unexpected-private-marker"])(
"rejects the malformed strict boolean %j without echoing it",
(malformedValue) => {
for (const environmentName of profileEnvironmentNames) {
const environment = environmentForProfile("main-writable");
environment[environmentName] = malformedValue;
expect(() => assertReleaseV2QaProfileEnvironment("main-writable", environment)).toThrow(environmentName);
try {
assertReleaseV2QaProfileEnvironment("main-writable", environment);
} catch (error) {
expect((error as Error).message).not.toContain(malformedValue || "unexpected-private-marker");
}
}
},
);
test.each(releaseV2QaProfiles)("rejects every mismatched flag for %s", (profile) => {
const expectedEnvironment = environmentForProfile(profile);
for (const environmentName of profileEnvironmentNames) {
const environment = { ...expectedEnvironment };
environment[environmentName] = environment[environmentName] === "true" ? "false" : "true";
expect(() => assertReleaseV2QaProfileEnvironment(profile, environment)).toThrow(environmentName);
}
});
test("rejects a writable ambient environment before a read-only reset or manifest write", async () => {
const disposableDirectory = await mkdtemp(path.join(tmpdir(), "homarr-release-v2-qa-profile-flags-"));
disposableDirectories.push(disposableDirectory);
const databasePath = path.join(disposableDirectory, "fixture.sqlite");
const outputDirectory = path.join(disposableDirectory, "output");
const database = new Database(databasePath);
database.exec("CREATE TABLE mutation_guard (value TEXT NOT NULL); INSERT INTO mutation_guard VALUES ('preserved')");
database.close();
const databaseBefore = await readFile(databasePath);
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
path.join(repoRoot, "scripts/release-v2-qa/seed.mts"),
"--profile",
"main-readonly",
"--output",
outputDirectory,
"--reset",
],
{
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
DB_DRIVER: "better-sqlite3",
DB_URL: databasePath,
DEMO_MODE: "true",
DEMO_READ_ONLY: "false",
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
},
timeout: 30_000,
},
);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr).toContain("DEMO_READ_ONLY (expected true)");
expect(result.stderr).not.toContain("no such table");
expect(await readFile(databasePath)).toEqual(databaseBefore);
await expect(access(path.join(outputDirectory, "fixture-manifest.json"))).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -0,0 +1,338 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { resolveCheckoutCandidateSha } from "./provenance.mts";
import { validateResolvedArtifactPath, writeSafeReportFile } from "./report-path-integrity.mts";
const repoRoot = path.resolve(import.meta.dirname, "../..");
const qaRoot = path.join(repoRoot, "qa/release-v2");
const candidateSha = resolveCheckoutCandidateSha(repoRoot);
type Status = "passed" | "failed" | "blocked" | "not-reached";
interface Packet {
id: string;
wave: string;
title: string;
prRefs: number[];
personas: string[];
boards: string[];
profiles: string[];
viewports: string[];
zooms: number[];
inputs: string[];
widgetKinds?: string[];
cases: string[];
}
interface Manifest {
caseDimensions: Record<string, { id: string; expected: string }[]>;
packets: Packet[];
}
interface Options {
packetId: string;
url: string;
profile: string;
persona: string;
session: string;
viewport: string;
input: string;
zoom: number;
timestamp: string;
}
const profileFlags: 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"],
"onboarding-fresh": ["DEMO_MODE=false", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
degraded: ["DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
};
const exhaustiveSizeWidgetKinds = new Set([
"downloads",
"calendar",
"mediaServer",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"systemResources",
"systemDisks",
"dockerContainers",
"networkControllerSummary",
"networkControllerStatus",
"beszelSystemStats",
"uptimeKuma",
"bookmarks",
"notebook",
"iframe",
"customApi",
"assistant",
]);
const parseOptions = (): Options => {
const values = new Map<string, string>();
const args = process.argv.slice(2).filter((argument) => argument !== "--");
for (let index = 0; index < args.length; index += 2) {
const key = args[index];
const value = args[index + 1];
if (!key?.startsWith("--") || !value) throw new Error(`Invalid argument ${key ?? "<missing>"}`);
values.set(key.slice(2), value);
}
const required = (key: string) => {
const value = values.get(key);
if (!value) throw new Error(`--${key} is required`);
return value;
};
const zoom = Number(required("zoom"));
if (!Number.isFinite(zoom) || zoom <= 0) throw new Error("--zoom must be a positive number");
return {
packetId: required("packet"),
url: required("url"),
profile: required("profile"),
persona: required("persona"),
session: required("session"),
viewport: required("viewport"),
input: required("input"),
zoom,
timestamp: values.get("timestamp") ?? new Date().toISOString(),
};
};
const listFiles = async (directory: string): Promise<string[]> => {
const output: string[] = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) output.push(...(await listFiles(entryPath)));
else if (entry.isFile()) {
const artifactError = await validateResolvedArtifactPath(directory, entryPath, "blocked-report artifact");
if (artifactError) throw new Error(artifactError);
const file = await lstat(entryPath);
if (!entryPath.endsWith(".webm") || file.size > 0) output.push(entryPath);
} else {
throw new Error(`Blocked-report artifact tree contains a non-regular entry: ${entry.name}`);
}
}
return output.toSorted();
};
const escapeCell = (value: string) => value.replaceAll("|", "\\|").replaceAll("\n", " ");
const list = (values: (string | number)[]) => {
if (values.length === 0) return "None";
return values.join(", ");
};
const sizeRequirement = (widgetKind: string) => {
if (exhaustiveSizeWidgetKinds.has(widgetKind)) return "every width 1-24 × every height 1-6";
return "minimum / canonical / wide / tall / maximum / overflow / behavior-changing thresholds";
};
const main = async () => {
const options = parseOptions();
const manifest = JSON.parse(await readFile(path.join(qaRoot, "coverage-manifest.json"), "utf8")) as Manifest;
const packet = manifest.packets.find((candidate) => candidate.id === options.packetId);
if (!packet) throw new Error(`Unknown packet ${options.packetId}`);
if (!packet.profiles.includes(options.profile)) throw new Error(`${packet.id} does not include ${options.profile}`);
if (!packet.personas.includes(options.persona)) throw new Error(`${packet.id} does not include ${options.persona}`);
if (!packet.viewports.includes(options.viewport))
throw new Error(`${packet.id} does not include ${options.viewport}`);
if (!packet.inputs.includes(options.input)) throw new Error(`${packet.id} does not include ${options.input}`);
if (!packet.zooms.includes(options.zoom)) throw new Error(`${packet.id} does not include ${options.zoom}%`);
const flags = profileFlags[options.profile];
if (!flags) throw new Error(`Unknown runtime profile ${options.profile}`);
const runtimeUrl = new URL(options.url);
const actualPort = Number(runtimeUrl.port);
if (!Number.isInteger(actualPort) || actualPort <= 0) throw new Error("Runtime URL must include an explicit port");
const artifactDirectory = path.join(repoRoot, ".screenshots/release-v2", packet.id);
const artifacts = await listFiles(artifactDirectory);
if (artifacts.length === 0) throw new Error(`${packet.id} has no browser artifacts`);
const dimensions = manifest.caseDimensions[packet.wave] ?? [];
const cases = packet.cases.flatMap((baseId) =>
dimensions.map((dimension) => ({ id: `${baseId}-${dimension.id}`, expected: dimension.expected })),
);
const caseStatuses: Record<string, Status> = {};
for (const testCase of cases) {
let status: Status = "blocked";
if (packet.wave === "preflight" && testCase.id.endsWith("-ENVIRONMENT")) status = "failed";
if (packet.wave === "preflight" && testCase.id.endsWith("-EVIDENCE")) status = "passed";
caseStatuses[testCase.id] = status;
}
const status: Status = Object.values(caseStatuses).includes("failed") ? "failed" : "blocked";
const blockedCaseIds = Object.entries(caseStatuses)
.filter(([, caseStatus]) => caseStatus !== "passed")
.map(([caseId]) => caseId);
const findingEvidence = artifacts.filter(
(artifact) => artifact.endsWith(".png") || artifact.endsWith(".webm") || artifact.includes("network"),
);
const evidence = findingEvidence.length > 0 ? findingEvidence : artifacts;
const hasRetryEvidence = artifacts.some((artifact) => /fresh|retry/u.test(path.basename(artifact)));
let findingSummary =
"Chromium repeatedly requests the same entry route and ends on ERR_TOO_MANY_REDIRECTS, preventing the assigned browser flow.";
let retryLimitation =
"The packet's retry did not produce a separate artifact; the same finding has independent reproductions in the preflight reports.";
let retryStep = "5. Compare the result with the independently reproduced preflight evidence.";
let reproducibility = "reproduced independently by the preflight agents";
if (hasRetryEvidence) {
findingSummary =
"A fresh Chromium session repeatedly requests the same entry route and ends on ERR_TOO_MANY_REDIRECTS; a fresh-context retry does the same.";
retryLimitation =
"The finding was retried in a fresh Chromium context; console output was empty while document requests repeated until Chromium stopped the loop.";
retryStep = "5. Close the context, retry in a fresh context, and observe the same result.";
reproducibility = "reproduced on retry and independently by the preflight agents";
}
const reproductionAgent = packet.id === "preflight-01" ? "qa-v2-preflight-02" : "qa-v2-preflight-01";
const widgetChecks = (packet.widgetKinds ?? []).flatMap((widgetKind) =>
packet.viewports.map((viewport) => ({
widgetKind,
viewport,
sizeRequirement: sizeRequirement(widgetKind),
status: "blocked" as const,
})),
);
const metadata = {
packetId: packet.id,
status,
caseStatuses,
execution: {
candidateSha,
url: options.url,
actualPort,
runtimeProfile: options.profile,
runtimeFlags: flags,
persona: options.persona,
sessionId: options.session,
timestamp: options.timestamp,
viewport: options.viewport,
input: options.input,
zoom: options.zoom,
},
findings: [
{
id: "RV2-P1-001",
fingerprint: "release-v2-auth-locale-redirect-loop",
severity: "P1",
area: "routing/authentication",
title: "Locale rewrite causes an infinite browser redirect before the application renders",
summary: findingSummary,
caseIds: blockedCaseIds,
evidence,
},
],
artifacts,
widgetChecks,
performance: {
measurements: [],
limitations: [
"The application never rendered, so assigned feature, mutation, persistence, permission, viewport, and performance checks were blocked.",
"No credentials were submitted and no product state was mutated.",
retryLimitation,
],
},
independentReproductions: [
{
findingFingerprint: "release-v2-auth-locale-redirect-loop",
agentId: reproductionAgent,
outcome: "reproduced",
evidence: [],
notes: `See ${reproductionAgent.replace("qa-v2-", "")} for an independent Luna Max reproduction.`,
},
],
notes: "Report-only black-box run. No defect was fixed and no secret was recorded.",
};
const caseRows = cases
.map((testCase) => {
const caseStatus = caseStatuses[testCase.id];
return `| ${testCase.id} | ${caseStatus} | [browser artifacts](${artifactDirectory}) | The application entry repeated redirects until Chromium stopped navigation; the assigned flow was not reachable. | ${escapeCell(testCase.expected)} |`;
})
.join("\n");
let widgetSection = "";
if (widgetChecks.length > 0) {
const widgetRows = widgetChecks
.map(
(check) =>
`| ${check.widgetKind} | ${check.viewport} | ${check.sizeRequirement} | blocked before render | blocked before authentication | not reached | redirect retry reproduced | [artifacts](${artifactDirectory}) | ${check.status} |`,
)
.join("\n");
widgetSection = `\n## Widget kind × viewport checklist\n\n| Widget kind | Viewport | Size | States | Permission/read-only | Options/persistence | Recovery | Evidence | Status |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${widgetRows}\n`;
}
const artifactRows = artifacts.map((artifact) => `- [${path.basename(artifact)}](${artifact})`).join("\n");
const report = `<!-- release-v2-qa-report
${JSON.stringify(metadata, null, 2)}
-->
# ${packet.id}: ${packet.title}
| Metadata | Value |
| --- | --- |
| Status | ${status} |
| Wave | ${packet.wave} |
| PR refs | ${packet.prRefs.map((number) => `#${number}`).join(", ")} |
| Personas | ${list(packet.personas)} |
| Boards | ${list(packet.boards)} |
| Profiles | ${list(packet.profiles)} |
| Chromium viewports | ${list(packet.viewports)} |
| Zoom | ${list(packet.zooms.map((zoom) => `${zoom}%`))} |
| Input | ${list(packet.inputs)} |
| Widget kinds | ${list(packet.widgetKinds ?? [])} |
| Artifact directory | [${artifactDirectory}](${artifactDirectory}) |
| Candidate SHA | ${candidateSha} |
| URL / actual port | ${options.url} / ${actualPort} |
| Runtime profile / flags | ${options.profile}; ${flags.join(", ")} |
| Executed persona | ${options.persona} |
| Browser session ID | ${options.session} |
| Timestamp | ${options.timestamp} |
| Executed viewport / input / zoom | ${options.viewport} / ${options.input} / ${options.zoom}% |
## Dogfood evidence
| Case ID | Status | Evidence | Observed | Expected |
| --- | --- | --- | --- | --- |
${caseRows}
${widgetSection}
## Performance measurements and limitations
No valid feature timing could be recorded because no application document rendered. Cold/warm, polling, rendering, memory, and recovery measurements remain blocked.
## Independent reproductions
The common routing failure was independently reproduced by ${reproductionAgent}; the corresponding tracked packet contains its own browser evidence.
## Findings
### RV2-P1-001 — P1: locale rewrite causes an infinite browser redirect
Preconditions: candidate ${candidateSha}, ${options.profile}, ${options.viewport}, ${options.zoom}% zoom, ${options.input}, fresh named Chromium session ${options.session}.
1. Launch a fresh Chromium context with the Chromium argument --no-sandbox.
2. Set the assigned viewport and open ${options.url} or its authentication entry.
3. Observe repeated document requests to the same route.
4. Wait until Chromium displays ERR_TOO_MANY_REDIRECTS or chrome-error://chromewebdata/.
${retryStep}
Expected: the application renders the assigned entry, permitting authentication and the packet flow. Actual: the redirect loop prevents the application from rendering. Reproducibility: ${reproducibility}. Console output was empty; network and visual evidence are linked below.
## Artifacts
${artifactRows}
## Mutations and persistence
No credentials were submitted and no product mutation occurred. Reload and fresh-session retries reproduced the routing failure, so application persistence was not reachable.
## Blockers and gaps
Every non-evidence case listed as blocked remains untested beyond the routing entry. No blocked cell is treated as passed.
## Cleanup
The named browser session was closed by the browser agent. Artifacts remain under the untracked packet directory; no secret values were captured.
`;
const reportPath = path.join(qaRoot, "reports", packet.id, "report.md");
await writeSafeReportFile(qaRoot, reportPath, report, `${packet.id} report path`);
console.log(`Normalized blocked browser report ${packet.id} with ${artifacts.length} artifact(s)`);
};
await main();
+663
View File
@@ -0,0 +1,663 @@
export type QaStatus = "passed" | "failed" | "blocked" | "not-reached";
export interface QaWidgetCheck {
widgetKind: string;
viewport: string;
sizeRequirement: string;
status: QaStatus;
}
export type QaSeverity = "P0" | "P1" | "P2" | "P3";
export interface QaFinding {
id?: string;
fingerprint?: string;
severity: QaSeverity;
title: string;
area?: string;
summary?: string;
caseIds?: string[];
evidence?: string[];
}
export interface QaIndependentReproduction {
findingFingerprint: string;
agentId: string;
outcome: string;
evidence: string[];
notes: string;
}
export interface AggregatedQaFinding extends QaFinding {
packets: string[];
caseIds: string[];
evidence: string[];
reproductions: QaIndependentReproduction[];
}
export interface QaReportMetadata {
packetId: string;
status: QaStatus;
caseStatuses: Record<string, QaStatus>;
execution: {
candidateSha: string | null;
url: string | null;
actualPort: number | null;
runtimeProfile: string | null;
runtimeFlags: string[];
persona: string | null;
sessionId: string | null;
timestamp: string | null;
viewport: string | null;
input: string | null;
zoom: number | null;
};
findings: QaFinding[];
artifacts: string[];
widgetChecks: QaWidgetCheck[];
performance: {
measurements: {
name: string;
value: number | null;
unit: string;
threshold: string;
status: QaStatus;
evidence: string[];
}[];
limitations: string[];
};
independentReproductions: QaIndependentReproduction[];
notes: string;
}
export interface CoverageGap {
packetId: string;
kind: "case" | "widget" | "metadata";
itemId: string;
status: QaStatus;
detail?: string;
}
interface NormalizeOptions {
packetId: string;
expectedCaseIds: string[];
expectedWidgetChecks: QaWidgetCheck[];
}
export interface ReportIntegrityOptions {
campaignCandidateSha: string;
packetId: string;
profiles: string[];
personas: string[];
viewports: string[];
zooms: number[];
inputs: string[];
expectedProfileFlags: Record<string, string[]>;
}
const allowedStatuses = new Set<QaStatus>(["passed", "failed", "blocked", "not-reached"]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const textOrNull = (value: unknown): string | null => (typeof value === "string" ? value : null);
const numberOrNull = (value: unknown): number | null => (typeof value === "number" ? value : null);
const strings = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
const statusOrNotReached = (value: unknown): QaStatus =>
allowedStatuses.has(value as QaStatus) ? (value as QaStatus) : "not-reached";
const severityOrP3 = (value: unknown): QaSeverity =>
["P0", "P1", "P2", "P3"].includes(String(value)) ? (value as QaSeverity) : "P3";
const checkKey = (check: Pick<QaWidgetCheck, "widgetKind" | "viewport">): string =>
`${check.widgetKind}\u0000${check.viewport}`;
const sameSet = <T,>(left: T[], right: T[]): boolean =>
left.length === right.length && new Set(left).size === left.length && left.every((value) => right.includes(value));
const severityRank: Record<QaSeverity, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };
const uniqueStrings = (values: string[]): string[] => [...new Set(values)];
const reproductionKey = (reproduction: QaIndependentReproduction): string =>
`${reproduction.findingFingerprint}\u0000${reproduction.agentId}\u0000${reproduction.outcome}`;
const mergeReproduction = (finding: AggregatedQaFinding, reproduction: QaIndependentReproduction): void => {
const key = reproductionKey(reproduction);
const existing = finding.reproductions.find((candidate) => reproductionKey(candidate) === key);
if (!existing) {
finding.reproductions.push({ ...reproduction, evidence: uniqueStrings(reproduction.evidence) });
return;
}
existing.evidence = uniqueStrings([...existing.evidence, ...reproduction.evidence]);
if (reproduction.notes && !existing.notes.includes(reproduction.notes)) {
existing.notes = [existing.notes, reproduction.notes].filter(Boolean).join("; ");
}
};
export const aggregateQaFindings = (
reports: {
packetId: string;
findings: QaFinding[];
independentReproductions: QaIndependentReproduction[];
}[],
): AggregatedQaFinding[] => {
const findings = new Map<string, AggregatedQaFinding>();
for (const report of reports) {
for (const finding of report.findings) {
const key =
finding.fingerprint ?? `${finding.severity}:${finding.area ?? "general"}:${finding.title}`.toLowerCase();
const existing = findings.get(key);
if (!existing) {
findings.set(key, {
...finding,
packets: [report.packetId],
caseIds: uniqueStrings(finding.caseIds ?? []),
evidence: uniqueStrings(finding.evidence ?? []),
reproductions: [],
});
continue;
}
existing.packets = uniqueStrings([...existing.packets, report.packetId]);
existing.caseIds = uniqueStrings([...existing.caseIds, ...(finding.caseIds ?? [])]);
existing.evidence = uniqueStrings([...existing.evidence, ...(finding.evidence ?? [])]);
if (severityRank[finding.severity] < severityRank[existing.severity]) {
existing.severity = finding.severity;
existing.id = finding.id ?? existing.id;
existing.title = finding.title;
existing.area = finding.area ?? existing.area;
existing.summary = finding.summary ?? existing.summary;
}
}
}
for (const report of reports) {
for (const reproduction of report.independentReproductions) {
const finding = findings.get(reproduction.findingFingerprint);
if (finding) mergeReproduction(finding, reproduction);
}
}
return [...findings.values()].toSorted(
(left, right) =>
severityRank[left.severity] - severityRank[right.severity] || left.title.localeCompare(right.title),
);
};
const markdownTableCells = (line: string): string[] | null => {
const trimmed = line.trim();
if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return null;
return trimmed
.slice(1, -1)
.split(/(?<!\\)\|/u)
.map((cell) => cell.trim().replaceAll("\\|", "|"));
};
export const collectHumanWidgetStatusErrors = (
content: string,
packetId: string,
expectedChecks: QaWidgetCheck[],
): string[] => {
const heading = "## Widget kind × viewport checklist";
const sectionStart = content.indexOf(heading);
if (expectedChecks.length === 0) {
if (sectionStart >= 0) return [`${packetId}: non-widget report must not contain a human widget checklist`];
return [];
}
if (sectionStart < 0) return [`${packetId}: human widget checklist is missing`];
const sectionBodyStart = sectionStart + heading.length;
const duplicateSection = content.indexOf(heading, sectionBodyStart);
const nextSection = content.indexOf("\n## ", sectionBodyStart);
const section = content.slice(sectionBodyStart, nextSection < 0 ? undefined : nextSection);
const rows = new Map<string, { status: string; count: number }>();
for (const line of section.split("\n")) {
const cells = markdownTableCells(line);
if (!cells || cells.length !== 9 || cells[0] === "Widget kind" || cells.every((cell) => /^-+$/u.test(cell))) {
continue;
}
const key = `${cells[0]}\u0000${cells[1]}`;
const existing = rows.get(key);
rows.set(key, { status: cells[8] ?? "", count: (existing?.count ?? 0) + 1 });
}
const errors: string[] = [];
if (duplicateSection >= 0) errors.push(`${packetId}: human widget checklist is duplicated`);
const expectedKeys = new Set(expectedChecks.map(checkKey));
for (const check of expectedChecks) {
const key = checkKey(check);
const row = rows.get(key);
if (!row) {
errors.push(`${packetId}: human widget row is missing ${check.widgetKind} at ${check.viewport}`);
continue;
}
if (row.count !== 1) {
errors.push(`${packetId}: human widget row is duplicated ${check.widgetKind} at ${check.viewport}`);
}
if (row.status !== check.status) {
errors.push(
`${packetId}/${check.widgetKind}/${check.viewport}: human widget status ${row.status || "<empty>"} differs from structured status ${check.status}`,
);
}
}
for (const key of rows.keys()) {
if (expectedKeys.has(key)) continue;
const [widgetKind, viewport] = key.split("\u0000");
errors.push(`${packetId}: human widget checklist contains unassigned row ${widgetKind}@${viewport}`);
}
return errors;
};
export const rollupQaStatuses = (statuses: QaStatus[]): QaStatus => {
if (statuses.includes("failed")) return "failed";
if (statuses.includes("blocked")) return "blocked";
if (statuses.includes("not-reached")) return "not-reached";
return "passed";
};
const hasExecutionResults = (metadata: QaReportMetadata): boolean =>
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;
export const collectReportIntegrityErrors = (metadata: QaReportMetadata, options: ReportIntegrityOptions): string[] => {
const errors: string[] = [];
if (metadata.packetId !== options.packetId) {
errors.push(`${options.packetId}: metadata packetId is ${metadata.packetId}`);
}
const caseRollup = rollupQaStatuses(Object.values(metadata.caseStatuses));
if (metadata.status !== caseRollup) {
errors.push(`${options.packetId}: report status ${metadata.status} does not match case rollup ${caseRollup}`);
}
if (metadata.execution.candidateSha !== options.campaignCandidateSha) {
errors.push(`${options.packetId}: candidateSha must equal the campaign candidate ${options.campaignCandidateSha}`);
}
if (!hasExecutionResults(metadata)) return errors;
const execution = metadata.execution;
const requiredText = {
candidateSha: execution.candidateSha,
url: execution.url,
runtimeProfile: execution.runtimeProfile,
persona: execution.persona,
sessionId: execution.sessionId,
timestamp: execution.timestamp,
viewport: execution.viewport,
input: execution.input,
};
for (const [field, value] of Object.entries(requiredText)) {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${options.packetId}: execution.${field} is required after execution`);
}
}
if (!Number.isInteger(execution.actualPort) || (execution.actualPort ?? 0) <= 0) {
errors.push(`${options.packetId}: execution.actualPort must be a positive integer after execution`);
}
if (!Number.isFinite(execution.zoom) || (execution.zoom ?? 0) <= 0) {
errors.push(`${options.packetId}: execution.zoom must be positive after execution`);
}
if (metadata.artifacts.length === 0) {
errors.push(`${options.packetId}: executed report must link at least one artifact`);
}
if (execution.runtimeProfile && !options.profiles.includes(execution.runtimeProfile)) {
errors.push(`${options.packetId}: execution.runtimeProfile is outside the packet assignment`);
}
if (execution.persona && !options.personas.includes(execution.persona)) {
errors.push(`${options.packetId}: execution.persona is outside the packet assignment`);
}
if (execution.viewport && !options.viewports.includes(execution.viewport)) {
errors.push(`${options.packetId}: execution.viewport is outside the packet assignment`);
}
if (execution.input && !options.inputs.includes(execution.input)) {
errors.push(`${options.packetId}: execution.input is outside the packet assignment`);
}
if (execution.zoom && !options.zooms.includes(execution.zoom)) {
errors.push(`${options.packetId}: execution.zoom is outside the packet assignment`);
}
const expectedFlags = execution.runtimeProfile ? options.expectedProfileFlags[execution.runtimeProfile] : undefined;
if (!expectedFlags || !sameSet(execution.runtimeFlags, expectedFlags)) {
errors.push(
`${options.packetId}: execution.runtimeFlags do not match profile ${execution.runtimeProfile ?? "unknown"}`,
);
}
if (execution.timestamp && Number.isNaN(Date.parse(execution.timestamp))) {
errors.push(`${options.packetId}: execution.timestamp must be ISO-compatible`);
}
if (execution.url) {
try {
const url = new URL(execution.url);
if (!["http:", "https:"].includes(url.protocol)) {
errors.push(`${options.packetId}: execution.url must use HTTP(S)`);
}
const defaultPort = url.protocol === "https:" ? 443 : 80;
const urlPort = url.port.length > 0 ? Number(url.port) : defaultPort;
if (execution.actualPort !== null && urlPort !== execution.actualPort) {
errors.push(`${options.packetId}: execution.url port does not match execution.actualPort`);
}
} catch {
errors.push(`${options.packetId}: execution.url must be an absolute HTTP(S) URL`);
}
}
return errors;
};
export const normalizeReportMetadata = (
value: unknown,
options: NormalizeOptions,
): { metadata: QaReportMetadata; errors: string[] } => {
const errors: string[] = [];
const root = isRecord(value) ? value : {};
if (!isRecord(value)) errors.push(`${options.packetId}: report metadata must be an object`);
if (typeof root.packetId !== "string") errors.push(`${options.packetId}: packetId must be a string`);
const caseStatuses: Record<string, QaStatus> = {};
if (!isRecord(root.caseStatuses)) {
errors.push(`${options.packetId}: caseStatuses must be an object`);
}
const rawCaseStatuses = isRecord(root.caseStatuses) ? root.caseStatuses : {};
for (const caseId of options.expectedCaseIds) {
const rawStatus = rawCaseStatuses[caseId];
if (rawStatus === undefined) {
errors.push(`${options.packetId}: caseStatuses is missing assigned case ${caseId}`);
} else if (!allowedStatuses.has(rawStatus as QaStatus)) {
errors.push(`${options.packetId}/${caseId}: invalid case status ${String(rawStatus)}`);
}
caseStatuses[caseId] = statusOrNotReached(rawStatus);
}
for (const caseId of Object.keys(rawCaseStatuses)) {
if (!options.expectedCaseIds.includes(caseId)) {
errors.push(`${options.packetId}: caseStatuses contains unknown case ${caseId}`);
}
}
if (!Array.isArray(root.widgetChecks)) {
errors.push(`${options.packetId}: widgetChecks must be an array`);
}
const expectedChecks = new Map(options.expectedWidgetChecks.map((check) => [checkKey(check), check]));
const suppliedChecks = new Map<string, QaWidgetCheck>();
for (const [index, valueAtIndex] of (Array.isArray(root.widgetChecks) ? root.widgetChecks : []).entries()) {
if (!isRecord(valueAtIndex)) {
errors.push(`${options.packetId}: widgetChecks[${index}] must be an object`);
continue;
}
const widgetKind = typeof valueAtIndex.widgetKind === "string" ? valueAtIndex.widgetKind : "";
const viewport = typeof valueAtIndex.viewport === "string" ? valueAtIndex.viewport : "";
const key = checkKey({ widgetKind, viewport });
if (!expectedChecks.has(key)) {
errors.push(`${options.packetId}: widgetChecks contains unknown row ${widgetKind}@${viewport}`);
continue;
}
if (suppliedChecks.has(key)) {
errors.push(`${options.packetId}: widgetChecks contains duplicate row ${widgetKind}@${viewport}`);
continue;
}
if (!allowedStatuses.has(valueAtIndex.status as QaStatus)) {
errors.push(`${options.packetId}/${widgetKind}/${viewport}: invalid widget check status`);
}
if (typeof valueAtIndex.sizeRequirement !== "string") {
errors.push(`${options.packetId}/${widgetKind}/${viewport}: sizeRequirement must be a string`);
} else if (valueAtIndex.sizeRequirement !== expectedChecks.get(key)?.sizeRequirement) {
errors.push(`${options.packetId}/${widgetKind}/${viewport}: sizeRequirement does not match the assignment`);
}
suppliedChecks.set(key, {
widgetKind,
viewport,
sizeRequirement: typeof valueAtIndex.sizeRequirement === "string" ? valueAtIndex.sizeRequirement : "",
status: statusOrNotReached(valueAtIndex.status),
});
}
const widgetChecks = options.expectedWidgetChecks.map((expected) => {
const supplied = suppliedChecks.get(checkKey(expected));
if (supplied) return supplied;
errors.push(
`${options.packetId}: widgetChecks is missing assigned row ${expected.widgetKind}@${expected.viewport}`,
);
return { ...expected, status: "not-reached" as const };
});
if (!Array.isArray(root.findings)) errors.push(`${options.packetId}: findings must be an array`);
const findings = (Array.isArray(root.findings) ? root.findings : []).flatMap((finding, index) => {
if (!isRecord(finding)) {
errors.push(`${options.packetId}: findings[${index}] must be an object`);
return [];
}
if (!["P0", "P1", "P2", "P3"].includes(String(finding.severity))) {
errors.push(`${options.packetId}: findings[${index}] has an invalid severity`);
}
if (typeof finding.title !== "string") {
errors.push(`${options.packetId}: findings[${index}].title must be a string`);
}
if (finding.caseIds !== undefined && !Array.isArray(finding.caseIds)) {
errors.push(`${options.packetId}: findings[${index}].caseIds must be an array`);
} else if (Array.isArray(finding.caseIds) && finding.caseIds.some((caseId) => typeof caseId !== "string")) {
errors.push(`${options.packetId}: findings[${index}].caseIds must contain only strings`);
} else if (
Array.isArray(finding.caseIds) &&
finding.caseIds.some((caseId) => !options.expectedCaseIds.includes(String(caseId)))
) {
errors.push(`${options.packetId}: findings[${index}].caseIds contains an unknown assigned case`);
}
if (finding.evidence !== undefined && !Array.isArray(finding.evidence)) {
errors.push(`${options.packetId}: findings[${index}].evidence must be an array`);
} else if (Array.isArray(finding.evidence) && finding.evidence.some((entry) => typeof entry !== "string")) {
errors.push(`${options.packetId}: findings[${index}].evidence must contain only strings`);
}
return [
{
id: typeof finding.id === "string" ? finding.id : undefined,
fingerprint: typeof finding.fingerprint === "string" ? finding.fingerprint : undefined,
severity: severityOrP3(finding.severity),
title: typeof finding.title === "string" ? finding.title : "",
area: typeof finding.area === "string" ? finding.area : undefined,
summary: typeof finding.summary === "string" ? finding.summary : undefined,
caseIds: Array.isArray(finding.caseIds) ? strings(finding.caseIds) : undefined,
evidence: Array.isArray(finding.evidence) ? strings(finding.evidence) : undefined,
},
];
});
if (!Array.isArray(root.artifacts)) errors.push(`${options.packetId}: artifacts must be an array`);
if (Array.isArray(root.artifacts) && root.artifacts.some((artifact) => typeof artifact !== "string")) {
errors.push(`${options.packetId}: artifacts must contain only strings`);
}
const artifacts = strings(root.artifacts);
const execution = isRecord(root.execution) ? root.execution : {};
if (!isRecord(root.execution)) errors.push(`${options.packetId}: execution metadata must be an object`);
if (!Array.isArray(execution.runtimeFlags)) {
errors.push(`${options.packetId}: execution.runtimeFlags must be an array`);
} else if (execution.runtimeFlags.some((flag) => typeof flag !== "string")) {
errors.push(`${options.packetId}: execution.runtimeFlags must contain only strings`);
}
for (const field of [
"candidateSha",
"url",
"runtimeProfile",
"persona",
"sessionId",
"timestamp",
"viewport",
"input",
]) {
const fieldValue = execution[field];
if (fieldValue !== undefined && fieldValue !== null && typeof fieldValue !== "string") {
errors.push(`${options.packetId}: execution.${field} must be a string or null`);
}
}
for (const field of ["actualPort", "zoom"]) {
const fieldValue = execution[field];
if (fieldValue !== undefined && fieldValue !== null && typeof fieldValue !== "number") {
errors.push(`${options.packetId}: execution.${field} must be a number or null`);
}
}
const performance = isRecord(root.performance) ? root.performance : {};
if (!isRecord(root.performance)) errors.push(`${options.packetId}: performance must be an object`);
if (!Array.isArray(performance.measurements)) {
errors.push(`${options.packetId}: performance.measurements must be an array`);
}
if (!Array.isArray(performance.limitations)) {
errors.push(`${options.packetId}: performance.limitations must be an array`);
} else if (performance.limitations.some((limitation) => typeof limitation !== "string")) {
errors.push(`${options.packetId}: performance.limitations must contain only strings`);
}
const measurements = (Array.isArray(performance.measurements) ? performance.measurements : []).flatMap(
(measurement, index) => {
if (!isRecord(measurement)) {
errors.push(`${options.packetId}: performance.measurements[${index}] must be an object`);
return [];
}
for (const field of ["name", "unit", "threshold"]) {
if (typeof measurement[field] !== "string") {
errors.push(`${options.packetId}: performance.measurements[${index}].${field} must be a string`);
}
}
if (measurement.value !== null && typeof measurement.value !== "number") {
errors.push(`${options.packetId}: performance.measurements[${index}].value must be a number or null`);
}
if (!allowedStatuses.has(measurement.status as QaStatus)) {
errors.push(`${options.packetId}: performance.measurements[${index}].status is invalid`);
}
if (!Array.isArray(measurement.evidence)) {
errors.push(`${options.packetId}: performance.measurements[${index}].evidence must be an array`);
} else if (measurement.evidence.some((entry) => typeof entry !== "string")) {
errors.push(`${options.packetId}: performance.measurements[${index}].evidence must contain only strings`);
}
return [
{
name: typeof measurement.name === "string" ? measurement.name : "",
value: numberOrNull(measurement.value),
unit: typeof measurement.unit === "string" ? measurement.unit : "",
threshold: typeof measurement.threshold === "string" ? measurement.threshold : "",
status: statusOrNotReached(measurement.status),
evidence: strings(measurement.evidence),
},
];
},
);
if (!Array.isArray(root.independentReproductions)) {
errors.push(`${options.packetId}: independentReproductions must be an array`);
}
const independentReproductions = (
Array.isArray(root.independentReproductions) ? root.independentReproductions : []
).flatMap((reproduction, index) => {
if (!isRecord(reproduction)) {
errors.push(`${options.packetId}: independentReproductions[${index}] must be an object`);
return [];
}
for (const field of ["findingFingerprint", "agentId", "notes"]) {
if (typeof reproduction[field] !== "string") {
errors.push(`${options.packetId}: independentReproductions[${index}].${field} must be a string`);
}
}
if (!["reproduced", "not-reproduced", "blocked", "not-reached"].includes(String(reproduction.outcome))) {
errors.push(`${options.packetId}: independentReproductions[${index}].outcome is invalid`);
}
if (!Array.isArray(reproduction.evidence)) {
errors.push(`${options.packetId}: independentReproductions[${index}].evidence must be an array`);
} else if (reproduction.evidence.some((entry) => typeof entry !== "string")) {
errors.push(`${options.packetId}: independentReproductions[${index}].evidence must contain only strings`);
}
return [
{
findingFingerprint: typeof reproduction.findingFingerprint === "string" ? reproduction.findingFingerprint : "",
agentId: typeof reproduction.agentId === "string" ? reproduction.agentId : "",
outcome: typeof reproduction.outcome === "string" ? reproduction.outcome : "not-reached",
evidence: strings(reproduction.evidence),
notes: typeof reproduction.notes === "string" ? reproduction.notes : "",
},
];
});
if (!allowedStatuses.has(root.status as QaStatus)) {
errors.push(`${options.packetId}: invalid report status ${String(root.status)}`);
}
if (typeof root.notes !== "string") errors.push(`${options.packetId}: notes must be a string`);
return {
metadata: {
packetId: typeof root.packetId === "string" ? root.packetId : options.packetId,
status: statusOrNotReached(root.status),
caseStatuses,
execution: {
candidateSha: textOrNull(execution.candidateSha),
url: textOrNull(execution.url),
actualPort: numberOrNull(execution.actualPort),
runtimeProfile: textOrNull(execution.runtimeProfile),
runtimeFlags: strings(execution.runtimeFlags),
persona: textOrNull(execution.persona),
sessionId: textOrNull(execution.sessionId),
timestamp: textOrNull(execution.timestamp),
viewport: textOrNull(execution.viewport),
input: textOrNull(execution.input),
zoom: numberOrNull(execution.zoom),
},
findings,
artifacts,
widgetChecks,
performance: {
measurements,
limitations: strings(performance.limitations),
},
independentReproductions,
notes: typeof root.notes === "string" ? root.notes : "",
},
errors,
};
};
export const collectCriticalCoverageGaps = (
reports: {
packetId: string;
caseStatuses: Record<string, QaStatus>;
widgetChecks: QaWidgetCheck[];
metadataErrors?: string[];
}[],
): CoverageGap[] =>
reports.flatMap((report) => {
const caseGaps = Object.entries(report.caseStatuses)
.filter(([, status]) => status !== "passed")
.map(([caseId, status]) => ({
packetId: report.packetId,
kind: "case" as const,
itemId: caseId,
status,
}));
const widgetGaps = report.widgetChecks
.filter((check) => check.status !== "passed")
.map((check) => ({
packetId: report.packetId,
kind: "widget" as const,
itemId: `${check.widgetKind}@${check.viewport}`,
status: check.status,
}));
const metadataGaps = (report.metadataErrors ?? []).map((detail, index) => ({
packetId: report.packetId,
kind: "metadata" as const,
itemId: `metadata-${index + 1}`,
status: "blocked" as const,
detail,
}));
return [...caseGaps, ...widgetGaps, ...metadataGaps];
});
export const releaseDecision = (severityTotals: { P0: number; P1: number }, gaps: CoverageGap[]): "GO" | "NO-GO" => {
if (severityTotals.P0 > 0 || severityTotals.P1 > 0 || gaps.length > 0) return "NO-GO";
return "GO";
};
@@ -0,0 +1,281 @@
import { describe, expect, it } from "vitest";
import {
aggregateQaFindings,
collectCriticalCoverageGaps,
collectHumanWidgetStatusErrors,
collectReportIntegrityErrors,
normalizeReportMetadata,
releaseDecision,
} from "./report-integrity.mts";
import type { QaReportMetadata, QaWidgetCheck, ReportIntegrityOptions } from "./report-integrity.mts";
const assignedWidgetCheck: QaWidgetCheck = {
widgetKind: "weather",
viewport: "320x568",
sizeRequirement: "minimum / canonical / wide / tall / maximum / overflow / behavior-changing thresholds",
status: "not-reached",
};
const campaignCandidateSha = "f57a660088d6777c86aca22977354fa8b810e2be";
const integrityOptions: ReportIntegrityOptions = {
campaignCandidateSha,
packetId: "preflight-01",
profiles: ["main-writable"],
personas: ["avery-admin"],
viewports: ["1280x900"],
zooms: [100],
inputs: ["mouse"],
expectedProfileFlags: {
"main-writable": ["DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
},
};
const validMetadata = (): QaReportMetadata => ({
packetId: "preflight-01",
status: "passed",
caseStatuses: { "PF01-FIXTURE-STATE": "passed" },
execution: {
candidateSha: campaignCandidateSha,
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: "avery-admin",
sessionId: "qa-v2-main-avery-admin",
timestamp: "2026-09-01T00:00:00.000Z",
viewport: "1280x900",
input: "mouse",
zoom: 100,
},
findings: [],
artifacts: ["/tmp/qa-artifact.png"],
widgetChecks: [],
performance: { measurements: [], limitations: [] },
independentReproductions: [],
notes: "Complete.",
});
describe("release-v2 report integrity", () => {
it("keeps the highest severity while merging duplicate finding evidence and reproductions", () => {
const fingerprint = "same-defect";
const findings = aggregateQaFindings([
{
packetId: "widgets-01",
findings: [
{
fingerprint,
severity: "P3",
title: "Initial low-severity title",
caseIds: ["W01-RENDER-STATE"],
evidence: ["/tmp/first.png"],
},
],
independentReproductions: [
{
findingFingerprint: fingerprint,
agentId: "reproducer-01",
outcome: "reproduced",
evidence: ["/tmp/repro-first.png"],
notes: "First replay.",
},
],
},
{
packetId: "widgets-02",
findings: [
{
fingerprint,
severity: "P1",
title: "Confirmed release blocker",
caseIds: ["W02-RENDER-STATE"],
evidence: ["/tmp/first.png", "/tmp/second.png"],
},
],
independentReproductions: [],
},
{
packetId: "repro-widgets-02",
findings: [],
independentReproductions: [
{
findingFingerprint: fingerprint,
agentId: "reproducer-01",
outcome: "reproduced",
evidence: ["/tmp/repro-second.png"],
notes: "Second replay.",
},
],
},
]);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: "P1",
title: "Confirmed release blocker",
packets: ["widgets-01", "widgets-02"],
caseIds: ["W01-RENDER-STATE", "W02-RENDER-STATE"],
evidence: ["/tmp/first.png", "/tmp/second.png"],
});
expect(findings[0]?.reproductions).toEqual([
{
findingFingerprint: fingerprint,
agentId: "reproducer-01",
outcome: "reproduced",
evidence: ["/tmp/repro-first.png", "/tmp/repro-second.png"],
notes: "First replay.; Second replay.",
},
]);
const severityTotals = { P0: 0, P1: findings.filter((finding) => finding.severity === "P1").length };
expect(releaseDecision(severityTotals, [])).toBe("NO-GO");
});
it("requires each human widget status to exactly match the structured status", () => {
const passedCheck = { ...assignedWidgetCheck, status: "passed" as const };
const content = `## Widget kind × viewport checklist
| Widget kind | Viewport | Size | States | Permission/read-only | Options/persistence | Recovery | Evidence | Status |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| weather | 320x568 | ${assignedWidgetCheck.sizeRequirement} | healthy | owner | reload | recovered | evidence | not-reached |
## Findings`;
expect(collectHumanWidgetStatusErrors(content, "widgets-01", [passedCheck])).toEqual([
"widgets-01/weather/320x568: human widget status not-reached differs from structured status passed",
]);
expect(
collectHumanWidgetStatusErrors(content.replace("| not-reached |", "| passed |"), "widgets-01", [passedCheck]),
).toEqual([]);
});
it("keeps the release NO-GO when cases pass but an assigned widget check is not reached", () => {
const gaps = collectCriticalCoverageGaps([
{
packetId: "widgets-01",
caseStatuses: { "W01-RENDER-STATE": "passed" },
widgetChecks: [assignedWidgetCheck],
},
]);
expect(gaps).toEqual([
{
packetId: "widgets-01",
kind: "widget",
itemId: "weather@320x568",
status: "not-reached",
},
]);
expect(releaseDecision({ P0: 0, P1: 0 }, gaps)).toBe("NO-GO");
});
it("reports null and malformed metadata without throwing", () => {
const normalize = () =>
normalizeReportMetadata(
{
packetId: "widgets-01",
status: "passed",
caseStatuses: null,
execution: { candidateSha: {}, runtimeFlags: null },
findings: null,
artifacts: null,
widgetChecks: null,
performance: null,
independentReproductions: null,
},
{
packetId: "widgets-01",
expectedCaseIds: ["W01-RENDER-STATE"],
expectedWidgetChecks: [assignedWidgetCheck],
},
);
expect(normalize).not.toThrow();
const result = normalize();
expect(result.errors).toEqual(
expect.arrayContaining([
"widgets-01: caseStatuses must be an object",
"widgets-01: findings must be an array",
"widgets-01: widgetChecks must be an array",
"widgets-01: execution.runtimeFlags must be an array",
"widgets-01: execution.candidateSha must be a string or null",
]),
);
expect(result.metadata.caseStatuses).toEqual({ "W01-RENDER-STATE": "not-reached" });
expect(result.metadata.findings).toEqual([]);
expect(result.metadata.widgetChecks).toEqual([assignedWidgetCheck]);
const gaps = collectCriticalCoverageGaps([
{
packetId: "widgets-01",
caseStatuses: result.metadata.caseStatuses,
widgetChecks: result.metadata.widgetChecks,
metadataErrors: result.errors,
},
]);
expect(gaps.some((gap) => gap.kind === "metadata")).toBe(true);
expect(releaseDecision({ P0: 0, P1: 0 }, gaps)).toBe("NO-GO");
});
it("turns a null metadata root into deterministic validation errors", () => {
const result = normalizeReportMetadata(null, {
packetId: "widgets-01",
expectedCaseIds: ["W01-RENDER-STATE"],
expectedWidgetChecks: [assignedWidgetCheck],
});
expect(result.errors[0]).toBe("widgets-01: report metadata must be an object");
expect(result.metadata.caseStatuses["W01-RENDER-STATE"]).toBe("not-reached");
expect(result.metadata.widgetChecks[0]?.status).toBe("not-reached");
});
it("forces a metadata gap and NO-GO for an executed report from the wrong candidate", () => {
const metadata = validMetadata();
metadata.execution.candidateSha = "0000000000000000000000000000000000000000";
const metadataErrors = collectReportIntegrityErrors(metadata, integrityOptions);
const gaps = collectCriticalCoverageGaps([
{ packetId: metadata.packetId, caseStatuses: metadata.caseStatuses, widgetChecks: [], metadataErrors },
]);
expect(metadataErrors).toContain(
`preflight-01: candidateSha must equal the campaign candidate ${campaignCandidateSha}`,
);
expect(gaps.some((gap) => gap.kind === "metadata")).toBe(true);
expect(releaseDecision({ P0: 0, P1: 0 }, gaps)).toBe("NO-GO");
});
it("forces a metadata gap and NO-GO when packet status disagrees with passed cases", () => {
const metadata = validMetadata();
metadata.status = "failed";
const metadataErrors = collectReportIntegrityErrors(metadata, integrityOptions);
const gaps = collectCriticalCoverageGaps([
{ packetId: metadata.packetId, caseStatuses: metadata.caseStatuses, widgetChecks: [], metadataErrors },
]);
expect(metadataErrors).toContain("preflight-01: report status failed does not match case rollup passed");
expect(releaseDecision({ P0: 0, P1: 0 }, gaps)).toBe("NO-GO");
});
it("reports missing execution metadata after any assigned case was reached", () => {
const metadata = validMetadata();
metadata.execution.url = null;
metadata.execution.actualPort = null;
expect(collectReportIntegrityErrors(metadata, integrityOptions)).toEqual(
expect.arrayContaining([
"preflight-01: execution.url is required after execution",
"preflight-01: execution.actualPort must be a positive integer after execution",
]),
);
});
it("leaves valid structured report metadata unchanged", () => {
const input = validMetadata();
const normalized = normalizeReportMetadata(input, {
packetId: "preflight-01",
expectedCaseIds: ["PF01-FIXTURE-STATE"],
expectedWidgetChecks: [],
});
expect(normalized.errors).toEqual([]);
expect(normalized.metadata).toEqual(input);
expect(collectReportIntegrityErrors(normalized.metadata, integrityOptions)).toEqual([]);
});
});
@@ -0,0 +1,74 @@
import { constants } from "node:fs";
import { lstat, open, realpath } from "node:fs/promises";
import path from "node:path";
import { assertSafeContainedPath, isPathWithin } from "./safety.mts";
const errorCode = (error: unknown): string | undefined => (error as NodeJS.ErrnoException).code;
export const assertSafeReportPath = async (qaRoot: string, target: string, label: string): Promise<string> =>
assertSafeContainedPath(qaRoot, target, label);
export const readSafeReportFile = async (qaRoot: string, target: string, label: string): Promise<string> => {
await assertSafeReportPath(qaRoot, target, label);
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
return await handle.readFile("utf8");
} finally {
await handle.close();
}
};
export const writeSafeReportFile = async (
qaRoot: string,
target: string,
content: string,
label: string,
): Promise<void> => {
await assertSafeReportPath(qaRoot, target, label);
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW;
const handle = await open(target, flags, 0o666);
try {
await handle.writeFile(content, "utf8");
} finally {
await handle.close();
}
};
export const validateResolvedArtifactPath = async (
expectedPacketDirectory: string,
artifactPath: string,
label: string,
): Promise<string | null> => {
if (!path.isAbsolute(artifactPath) || !isPathWithin(expectedPacketDirectory, artifactPath)) {
return `${label} must use the packet's absolute artifact directory`;
}
let resolvedPacketDirectory: string;
try {
resolvedPacketDirectory = await realpath(expectedPacketDirectory);
} catch (error) {
return `${label} packet artifact directory does not resolve (${errorCode(error) ?? "unknown error"})`;
}
if (resolvedPacketDirectory !== path.resolve(expectedPacketDirectory)) {
return `${label} packet artifact directory must not contain symlinks`;
}
let resolvedArtifact: string;
try {
resolvedArtifact = await realpath(artifactPath);
} catch (error) {
return `${label} does not resolve (${errorCode(error) ?? "unknown error"})`;
}
if (!isPathWithin(resolvedPacketDirectory, resolvedArtifact)) {
return `${label} resolves outside the packet artifact directory`;
}
try {
await assertSafeContainedPath(resolvedPacketDirectory, artifactPath, label);
const artifactStats = await lstat(artifactPath);
if (!artifactStats.isFile()) return `${label} must resolve to an existing regular file`;
} catch (error) {
return `${label} is not a safe regular file (${error instanceof Error ? error.message : "unknown error"})`;
}
return null;
};
@@ -0,0 +1,120 @@
// @vitest-environment node
import { spawnSync } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertSafeReportPath,
readSafeReportFile,
validateResolvedArtifactPath,
writeSafeReportFile,
} from "./report-path-integrity.mts";
const temporaryPaths: string[] = [];
const temporaryDirectory = async (suffix: string): Promise<string> => {
const directory = await mkdtemp(path.join(tmpdir(), `homarr-release-v2-qa-${suffix}-`));
temporaryPaths.push(directory);
return directory;
};
afterEach(async () => {
await Promise.all(temporaryPaths.splice(0).map((target) => rm(target, { force: true, recursive: true })));
});
describe("release-v2 QA report path integrity", () => {
it.each(["reports/preflight-01/report.md", "ledger.json", "master-report.md"])(
"rejects a symlinked aggregate input or output path: %s",
async (relativePath) => {
const qaRoot = await temporaryDirectory("report-path");
const target = path.join(qaRoot, "outside-target");
const linkedPath = path.join(qaRoot, relativePath);
await mkdir(path.dirname(linkedPath), { recursive: true });
await writeFile(target, "outside");
await symlink(target, linkedPath);
await expect(assertSafeReportPath(qaRoot, linkedPath, "QA report path")).rejects.toThrow("symlink");
await expect(readSafeReportFile(qaRoot, linkedPath, "QA report path")).rejects.toThrow("symlink");
await expect(writeSafeReportFile(qaRoot, linkedPath, "replacement", "QA report path")).rejects.toThrow("symlink");
await expect(readFile(target, "utf8")).resolves.toBe("outside");
},
);
it("rejects an artifact symlink that ultimately escapes its packet directory", async () => {
const root = await temporaryDirectory("artifact-escape");
const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01");
const outsideArtifact = path.join(root, "outside.png");
const linkedArtifact = path.join(packetDirectory, "escape.png");
await mkdir(packetDirectory, { recursive: true });
await writeFile(outsideArtifact, "outside");
await symlink(outsideArtifact, linkedArtifact);
await expect(
validateResolvedArtifactPath(packetDirectory, linkedArtifact, "preflight-01: artifact"),
).resolves.toContain("resolves outside");
});
it("accepts a resolved regular artifact inside the packet directory", async () => {
const root = await temporaryDirectory("artifact-valid");
const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01");
const artifact = path.join(packetDirectory, "valid.png");
await mkdir(packetDirectory, { recursive: true });
await writeFile(artifact, "valid");
await expect(validateResolvedArtifactPath(packetDirectory, artifact, "preflight-01: artifact")).resolves.toBeNull();
});
it("rejects an artifact directory and an in-directory artifact symlink", async () => {
const root = await temporaryDirectory("artifact-not-file");
const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01");
const artifactDirectory = path.join(packetDirectory, "looks-like-evidence.png");
const realArtifact = path.join(packetDirectory, "real.png");
const linkedArtifact = path.join(packetDirectory, "linked.png");
await mkdir(artifactDirectory, { recursive: true });
await writeFile(realArtifact, "valid");
await symlink(realArtifact, linkedArtifact);
await expect(
validateResolvedArtifactPath(packetDirectory, artifactDirectory, "preflight-01: artifact"),
).resolves.toContain("regular file");
await expect(
validateResolvedArtifactPath(packetDirectory, linkedArtifact, "preflight-01: artifact"),
).resolves.toContain("symlink");
});
it.runIf(process.platform !== "win32")("rejects a FIFO artifact", async () => {
const root = await temporaryDirectory("artifact-fifo");
const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01");
const fifoArtifact = path.join(packetDirectory, "capture.pipe");
await mkdir(packetDirectory, { recursive: true });
const result = spawnSync("mkfifo", [fifoArtifact]);
expect(result.status).toBe(0);
await expect(
validateResolvedArtifactPath(packetDirectory, fifoArtifact, "preflight-01: artifact"),
).resolves.toContain("regular file");
});
it.runIf(process.platform !== "win32")("rejects a socket artifact", async () => {
const root = await temporaryDirectory("artifact-socket");
const packetDirectory = path.join(root, ".screenshots", "release-v2", "preflight-01");
const socketArtifact = path.join(packetDirectory, "capture.sock");
await mkdir(packetDirectory, { recursive: true });
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketArtifact, resolve);
});
try {
await expect(
validateResolvedArtifactPath(packetDirectory, socketArtifact, "preflight-01: artifact"),
).resolves.toContain("regular file");
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
});
+630
View File
@@ -0,0 +1,630 @@
import { mkdir, readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
aggregateQaFindings,
collectCriticalCoverageGaps,
collectHumanWidgetStatusErrors,
collectReportIntegrityErrors,
normalizeReportMetadata,
releaseDecision,
} from "./report-integrity.mts";
import {
assertSafeReportPath,
readSafeReportFile,
validateResolvedArtifactPath,
writeSafeReportFile,
} from "./report-path-integrity.mts";
import { resolveCheckoutCandidateSha } from "./provenance.mts";
type Status = "passed" | "failed" | "blocked" | "not-reached";
type Severity = "P0" | "P1" | "P2" | "P3";
interface Packet {
id: string;
wave: string;
title: string;
prRefs: number[];
personas: string[];
boards: string[];
profiles: string[];
viewports: string[];
zooms: number[];
inputs: string[];
widgetKinds?: string[];
cases: string[];
}
interface Manifest {
pullRequests: { number: number; label: string }[];
caseDimensions: Record<string, { id: string; expected: string }[]>;
packets: Packet[];
}
interface Finding {
id?: string;
fingerprint?: string;
severity: Severity;
title: string;
area?: string;
summary?: string;
caseIds?: string[];
evidence?: string[];
}
interface ReportMetadata {
packetId: string;
status: Status;
caseStatuses: Record<string, Status>;
execution: {
candidateSha: string | null;
url: string | null;
actualPort: number | null;
runtimeProfile: string | null;
runtimeFlags: string[];
persona: string | null;
sessionId: string | null;
timestamp: string | null;
viewport: string | null;
input: string | null;
zoom: number | null;
};
findings: Finding[];
artifacts: string[];
widgetChecks: WidgetCheck[];
performance: {
measurements: PerformanceMeasurement[];
limitations: string[];
};
independentReproductions: IndependentReproduction[];
notes: string;
}
interface WidgetCheck {
widgetKind: string;
viewport: string;
sizeRequirement: string;
status: Status;
}
interface PerformanceMeasurement {
name: string;
value: number | null;
unit: string;
threshold: string;
status: Status;
evidence: string[];
}
interface IndependentReproduction {
findingFingerprint: string;
agentId: string;
outcome: "reproduced" | "not-reproduced" | "blocked" | "not-reached";
evidence: string[];
notes: string;
}
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 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"],
"onboarding-fresh": ["DEMO_MODE=false", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
degraded: ["DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
};
const exhaustiveSizeWidgetKinds = new Set([
"downloads",
"calendar",
"mediaServer",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"systemResources",
"systemDisks",
"dockerContainers",
"networkControllerSummary",
"networkControllerStatus",
"beszelSystemStats",
"uptimeKuma",
"bookmarks",
"notebook",
"iframe",
"customApi",
"assistant",
]);
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 escapeCell = (value: string): string => value.replaceAll("|", "\\|").replaceAll("\n", " ");
const list = (values: (string | number)[]): string => (values.length === 0 ? "None" : values.join(", "));
const expandedCases = (packet: Packet): { id: string; expected: string }[] => {
const dimensions = manifest.caseDimensions[packet.wave] ?? [];
return packet.cases.flatMap((baseId) =>
dimensions.map((dimension) => ({ id: `${baseId}-${dimension.id}`, expected: dimension.expected })),
);
};
const sizeRequirement = (widgetKind: string) => {
if (exhaustiveSizeWidgetKinds.has(widgetKind)) {
return "every width 1-24 × every height 1-6";
}
return "minimum / canonical / wide / tall / maximum / overflow / behavior-changing thresholds";
};
const widgetChecks = (packet: Packet): WidgetCheck[] =>
(packet.widgetKinds ?? []).flatMap((widgetKind) =>
packet.viewports.map((viewport) => ({
widgetKind,
viewport,
sizeRequirement: sizeRequirement(widgetKind),
status: "not-reached",
})),
);
const reportMetadata = (packet: Packet): ReportMetadata => ({
packetId: packet.id,
status: "not-reached",
caseStatuses: Object.fromEntries(expandedCases(packet).map(({ id }) => [id, "not-reached" as const])),
execution: {
candidateSha: campaignCandidateSha,
url: null,
actualPort: null,
runtimeProfile: null,
runtimeFlags: [],
persona: null,
sessionId: null,
timestamp: null,
viewport: null,
input: null,
zoom: null,
},
findings: [],
artifacts: [],
widgetChecks: widgetChecks(packet),
performance: { measurements: [], limitations: [] },
independentReproductions: [],
notes: "Not executed.",
});
const renderPacketReportBase = (packet: Packet): string => {
const artifactDirectory = resolve(repoRoot, ".screenshots/release-v2", packet.id);
const metadata = reportMetadata(packet);
const cases = expandedCases(packet)
.map(
({ id, expected }) =>
`| ${id} | not-reached | [planned screenshot](${artifactDirectory}/${id}.png) | Not executed | ${expected} |`,
)
.join("\n");
const widgetMatrix = metadata.widgetChecks
.map(
(check) =>
`| ${check.widgetKind} | ${check.viewport} | ${check.sizeRequirement} | loading / populated / empty / error | Owner / Editor / Viewer / read-only | mutate / save / reload / reset | fail / retry / healthy | [planned artifact](${artifactDirectory}/${check.widgetKind}-${check.viewport}.png) | ${check.status} |`,
)
.join("\n");
const widgetSection =
widgetMatrix.length === 0
? ""
: `\n## Widget kind × viewport checklist\n\n| Widget kind | Viewport | Size | States | Permission/read-only | Options/persistence | Recovery | Evidence | Status |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${widgetMatrix}\n`;
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 =>
renderPacketReportBase(packet)
.replace("| Candidate SHA | not-recorded |", `| Candidate SHA | ${campaignCandidateSha} |`)
.replace(
"\n## Findings",
"\n## Performance measurements and limitations\n\nRecord structured measurements in `performance.measurements` with units, thresholds, status, and evidence. Record every environmental or tooling limitation in `performance.limitations`; leave measurements empty instead of inventing a result.\n\n## Independent reproductions\n\nRecord a second agent's result in `independentReproductions` with the finding fingerprint, agent ID, outcome, evidence, and notes. An unattempted reproduction remains `not-reached`.\n\n## Findings",
);
const parseMetadata = (content: string, packet: Packet): { metadata: ReportMetadata; errors: string[] } => {
const metadata = content.match(/<!-- release-v2-qa-report\n([\s\S]*?)\n-->/)?.[1];
let rawMetadata: unknown = null;
const parseErrors: string[] = [];
if (!metadata) {
parseErrors.push(`${packet.id}: report metadata block is missing`);
} else {
try {
rawMetadata = JSON.parse(metadata) as unknown;
} catch (error) {
parseErrors.push(`${packet.id}: report metadata is invalid JSON (${String(error)})`);
}
}
const normalized = normalizeReportMetadata(rawMetadata, {
packetId: packet.id,
expectedCaseIds: expandedCases(packet).map(({ id }) => id),
expectedWidgetChecks: widgetChecks(packet),
});
return {
metadata: normalized.metadata as ReportMetadata,
errors: [...parseErrors, ...normalized.errors],
};
};
const collectHumanReportErrors = (content: string, packet: Packet, metadata: ReportMetadata): string[] => {
const errors: string[] = [];
if (!content.includes(`| Status | ${metadata.status} |`)) {
errors.push(`${packet.id}: human metadata status differs from structured metadata`);
}
if (!content.includes(`| Candidate SHA | ${campaignCandidateSha} |`)) {
errors.push(`${packet.id}: human metadata does not show the campaign candidate`);
}
for (const [caseId, status] of Object.entries(metadata.caseStatuses)) {
if (!content.includes(`| ${caseId} | ${status} |`)) {
errors.push(`${packet.id}/${caseId}: human evidence row/status is missing`);
}
}
errors.push(...collectHumanWidgetStatusErrors(content, packet.id, metadata.widgetChecks));
const hasExecution =
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;
if (!hasExecution) return errors;
const requiredValues = [
metadata.execution.candidateSha,
metadata.execution.url,
metadata.execution.actualPort,
metadata.execution.runtimeProfile,
metadata.execution.persona,
metadata.execution.sessionId,
metadata.execution.timestamp,
metadata.execution.viewport,
metadata.execution.input,
metadata.execution.zoom,
];
for (const value of requiredValues) {
if (value !== null && !content.includes(String(value))) {
errors.push(`${packet.id}: human metadata does not include execution value ${String(value)}`);
}
}
return errors;
};
const collectArtifactErrors = async (packet: Packet, metadata: ReportMetadata): Promise<string[]> => {
const packetArtifactDirectory = resolve(repoRoot, ".screenshots/release-v2", packet.id);
const artifacts = [
...metadata.artifacts.map((artifact) => ({ artifact, label: `${packet.id}: artifact` })),
...metadata.findings.flatMap((finding, findingIndex) =>
(finding.evidence ?? []).map((artifact) => ({
artifact,
label: `${packet.id}: findings[${findingIndex}] evidence`,
})),
),
...metadata.performance.measurements.flatMap((measurement, measurementIndex) =>
measurement.evidence.map((artifact) => ({
artifact,
label: `${packet.id}: performance.measurements[${measurementIndex}] evidence`,
})),
),
...metadata.independentReproductions.flatMap((reproduction, reproductionIndex) =>
reproduction.evidence.map((artifact) => ({
artifact,
label: `${packet.id}: independentReproductions[${reproductionIndex}] evidence`,
})),
),
];
const errors: string[] = [];
for (const { artifact, label } of artifacts) {
const error = await validateResolvedArtifactPath(packetArtifactDirectory, artifact, label);
if (error) errors.push(error);
}
return errors;
};
const ensureReports = async (): Promise<void> => {
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) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
await writeSafeReportFile(qaRoot, reportPath, renderPacketReport(packet), `${packet.id} report path`);
}
}
};
interface CoverageAssignment {
label: string;
detail?: string;
status: Status;
}
const rollupStatuses = (statuses: Status[]): Status => {
if (statuses.includes("failed")) return "failed";
if (statuses.includes("blocked")) return "blocked";
if (statuses.includes("not-reached")) return "not-reached";
return "passed";
};
const matchingCaseStatus = (metadata: ReportMetadata, fragments: string[]) => {
const statuses = Object.entries(metadata.caseStatuses)
.filter(([caseId]) => fragments.some((fragment) => caseId.includes(fragment)))
.map(([, status]) => status);
if (statuses.length === 0) return metadata.status;
return rollupStatuses(statuses);
};
const coverageTable = (assignments: CoverageAssignment[], labelHeading: string) => {
const grouped = new Map<string, { detail: string; statuses: Status[] }>();
for (const assignment of assignments) {
const existing = grouped.get(assignment.label);
if (existing) {
existing.statuses.push(assignment.status);
continue;
}
grouped.set(assignment.label, { detail: assignment.detail ?? "—", statuses: [assignment.status] });
}
if (grouped.size === 0)
return `| ${labelHeading} | Detail | Passed | Failed | Blocked | Not reached | Total |\n| --- | --- | ---: | ---: | ---: | ---: | ---: |\n| — | Not recorded | 0 | 0 | 0 | 0 | 0 |`;
const rows = [...grouped.entries()]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([label, value]) => {
const count = (status: Status) => value.statuses.filter((current) => current === status).length;
return `| ${escapeCell(label)} | ${escapeCell(value.detail)} | ${count("passed")} | ${count("failed")} | ${count("blocked")} | ${count("not-reached")} | ${value.statuses.length} |`;
});
return `| ${labelHeading} | Detail | Passed | Failed | Blocked | Not reached | Total |\n| --- | --- | ---: | ---: | ---: | ---: | ---: |\n${rows.join("\n")}`;
};
const main = async (): Promise<void> => {
if (init || refresh) await ensureReports();
const reports: { packet: Packet; metadata: ReportMetadata; metadataErrors: string[] }[] = [];
for (const packet of manifest.packets) {
const reportPath = join(reportsRoot, packet.id, "report.md");
await assertSafeReportPath(qaRoot, reportPath, `${packet.id} report path`);
let content = "";
const readErrors: string[] = [];
try {
content = await readSafeReportFile(qaRoot, reportPath, `${packet.id} report path`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
readErrors.push(`${packet.id}: report.md is missing`);
}
const parsed = parseMetadata(content, packet);
const integrityErrors = collectReportIntegrityErrors(parsed.metadata, {
campaignCandidateSha,
packetId: packet.id,
profiles: packet.profiles,
personas: packet.personas,
viewports: packet.viewports,
zooms: packet.zooms,
inputs: packet.inputs,
expectedProfileFlags,
});
const humanReportErrors = collectHumanReportErrors(content, packet, parsed.metadata);
const artifactErrors = await collectArtifactErrors(packet, parsed.metadata);
reports.push({
packet,
metadata: parsed.metadata,
metadataErrors: [...readErrors, ...parsed.errors, ...integrityErrors, ...humanReportErrors, ...artifactErrors],
});
}
const packetTotals: Record<Status, number> = { passed: 0, failed: 0, blocked: 0, "not-reached": 0 };
const caseTotals: Record<Status, number> = { passed: 0, failed: 0, blocked: 0, "not-reached": 0 };
for (const { metadata } of reports) {
packetTotals[metadata.status] += 1;
for (const status of Object.values(metadata.caseStatuses)) caseTotals[status] += 1;
}
const findings = aggregateQaFindings(
reports.map(({ packet, metadata }) => ({
packetId: packet.id,
findings: metadata.findings,
independentReproductions: metadata.independentReproductions,
})),
);
const severityTotals: Record<Severity, number> = { P0: 0, P1: 0, P2: 0, P3: 0 };
for (const finding of findings) severityTotals[finding.severity] += 1;
const criticalGaps = collectCriticalCoverageGaps(
reports.map(({ packet, metadata, metadataErrors }) => ({
packetId: packet.id,
caseStatuses: metadata.caseStatuses,
widgetChecks: metadata.widgetChecks,
metadataErrors,
})),
);
const decision = releaseDecision(severityTotals, criticalGaps);
const widgetTotals: Record<Status, number> = { passed: 0, failed: 0, blocked: 0, "not-reached": 0 };
for (const { metadata } of reports) {
for (const check of metadata.widgetChecks) widgetTotals[check.status] += 1;
}
const ledger = {
schemaVersion: 1,
updatedAt: new Date().toISOString(),
packetStatuses: Object.fromEntries(
reports.map(({ packet, metadata }) => [packet.id, { status: metadata.status, cases: metadata.caseStatuses }]),
),
};
const ledgerPath = join(qaRoot, "ledger.json");
const masterReportPath = join(qaRoot, "master-report.md");
await assertSafeReportPath(qaRoot, ledgerPath, "release-v2 QA ledger path");
await assertSafeReportPath(qaRoot, masterReportPath, "release-v2 QA master report path");
await writeSafeReportFile(qaRoot, ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "release-v2 QA ledger path");
const waveRows = Object.entries(Object.groupBy(reports, ({ packet }) => packet.wave)).map(([wave, entries]) => {
const waveReports = entries ?? [];
const passed = waveReports.filter(({ metadata }) => metadata.status === "passed").length;
return `| ${wave} | ${passed} | ${waveReports.length} |`;
});
const findingRows =
findings.length === 0
? "| — | — | No findings recorded | — | — |"
: findings
.map(
(finding) =>
`| ${finding.severity} | ${escapeCell(finding.area ?? "general")} | ${escapeCell(finding.title)} | ${list(finding.packets)} | ${list(finding.caseIds)} |`,
)
.join("\n");
const gapRows =
criticalGaps.length === 0
? "| — | — | — | — |"
: criticalGaps
.map(
(gap) =>
`| ${gap.packetId} | ${gap.kind} | ${escapeCell(gap.itemId)} | ${gap.status}${gap.detail ? ` — ${escapeCell(gap.detail)}` : ""} |`,
)
.join("\n");
const prRows = manifest.pullRequests
.map(
(pullRequest) =>
`| #${pullRequest.number} | ${pullRequest.label} | ${manifest.packets.filter((packet) => packet.prRefs.includes(pullRequest.number)).length} |`,
)
.join("\n");
const prCoverage = coverageTable(
manifest.pullRequests.flatMap((pullRequest) =>
reports
.filter(({ packet }) => packet.prRefs.includes(pullRequest.number))
.map(({ metadata }) => ({
label: `#${pullRequest.number}`,
detail: pullRequest.label,
status: metadata.status,
})),
),
"PR",
);
const featureCoverage = coverageTable(
reports.map(({ packet, metadata }) => ({ label: packet.id, detail: packet.title, status: metadata.status })),
"Feature / agent",
);
const widgetAssignments = reports.flatMap(({ packet, metadata }) =>
(metadata.widgetChecks ?? widgetChecks(packet)).map((check) => ({
label: check.widgetKind,
detail: packet.id,
status: check.status,
})),
);
const widgetCoverage = coverageTable(widgetAssignments, "Widget kind");
const sizeCoverage = coverageTable(
reports.flatMap(({ packet, metadata }) =>
(metadata.widgetChecks ?? widgetChecks(packet)).map((check) => ({
label: check.widgetKind,
detail: check.sizeRequirement,
status: check.status,
})),
),
"Widget size threshold",
);
const viewportCoverage = coverageTable(
reports.flatMap(({ packet, metadata }) =>
packet.viewports.map((viewport) => ({ label: viewport, detail: packet.id, status: metadata.status })),
),
"Viewport",
);
const personaCoverage = coverageTable(
reports.flatMap(({ packet, metadata }) =>
packet.personas.map((persona) => ({ label: persona, detail: packet.id, status: metadata.status })),
),
"Persona",
);
const permissionCoverage = coverageTable(
reports.flatMap(({ packet, metadata }) => {
const status = matchingCaseStatus(metadata, ["ACCESS", "PERMISSION", "READONLY"]);
return [
...packet.personas.map((persona) => ({ label: persona, detail: packet.id, status })),
...packet.profiles.map((profile) => ({ label: `profile:${profile}`, detail: packet.id, status })),
];
}),
"Permission boundary",
);
const mutationCoverage = coverageTable(
reports.map(({ packet, metadata }) => ({
label: packet.id,
detail: packet.title,
status: matchingCaseStatus(metadata, ["MUTATION", "PERSISTENCE", "OPTIONS"]),
})),
"Mutation area",
);
const stateCoverage = coverageTable(
reports.map(({ packet, metadata }) => ({
label: packet.id,
detail: packet.title,
status: matchingCaseStatus(metadata, ["STATE", "DEGRADED", "RECOVERY", "FAILURE", "EVIDENCE"]),
})),
"State / recovery area",
);
const performanceMeasurements = reports.flatMap(({ packet, metadata }) =>
(metadata.performance?.measurements ?? []).map((measurement) => ({ packet, measurement })),
);
const performanceRows =
performanceMeasurements.length === 0
? "| — | No measurement recorded | — | — | not-reached | — |"
: performanceMeasurements
.map(({ packet, measurement }) => {
const value = measurement.value === null ? "not-recorded" : `${measurement.value} ${measurement.unit}`;
return `| ${packet.id} | ${escapeCell(measurement.name)} | ${escapeCell(value)} | ${escapeCell(measurement.threshold)} | ${measurement.status} | ${list(measurement.evidence)} |`;
})
.join("\n");
const limitationRows = [
`| host-runtime | ${escapeCell(hostRuntimeLimitation)} |`,
...reports.flatMap(({ packet, metadata }) =>
(metadata.performance?.limitations ?? []).map((limitation) => `| ${packet.id} | ${escapeCell(limitation)} |`),
),
];
const reproductionRows = findings.flatMap((finding) =>
finding.reproductions.map(
(reproduction) =>
`| ${list(finding.packets)} | ${escapeCell(reproduction.findingFingerprint)} | ${escapeCell(reproduction.agentId)} | ${reproduction.outcome} | ${list(reproduction.evidence)} | ${escapeCell(reproduction.notes)} |`,
),
);
const coverageAppendix = `## Coverage by required axis\n\n### Pull request\n\n${prCoverage}\n\n### Feature\n\n${featureCoverage}\n\n### Widget\n\n${widgetCoverage}\n\n### Size threshold\n\nHigh-risk widgets require every width 1–24 × every height 1–6 at all assigned mobile, breakpoint-edge, and desktop viewports. Other widgets require minimum, canonical, wide, tall, maximum, overflow, and behavior-changing threshold checks.\n\n${sizeCoverage}\n\n### Viewport\n\n${viewportCoverage}\n\n### Persona\n\n${personaCoverage}\n\n### Permission\n\n${permissionCoverage}\n\n### Mutation\n\n${mutationCoverage}\n\n### State and recovery\n\n${stateCoverage}\n\n## Performance measurements\n\n| Agent | Measurement | Value | Threshold | Status | Evidence |\n| --- | --- | --- | --- | --- | --- |\n${performanceRows}\n\n### Performance limitations\n\n| Agent | Limitation |\n| --- | --- |\n${limitationRows.length > 0 ? limitationRows.join("\n") : "| — | Not recorded |"}\n\n## Independent reproduction results\n\n| Agent | Finding fingerprint | Reproducing agent | Outcome | Evidence | Notes |\n| --- | --- | --- | --- | --- | --- |\n${reproductionRows.length > 0 ? reproductionRows.join("\n") : "| — | — | — | not-reached | — | Not recorded |"}\n\n`;
const master = `# Release-v2 QA master report\n\nGenerated: ${new Date().toISOString()}\n\n## Decision: ${decision}\n\nGO requires zero P0/P1 findings and zero critical gaps across assigned cases, widget checks, and report metadata. Current critical gaps: **${criticalGaps.length}**.\n\n## Coverage\n\n| Measure | Passed | Failed | Blocked | Not reached | Total |\n| --- | ---: | ---: | ---: | ---: | ---: |\n| Packets | ${packetTotals.passed} | ${packetTotals.failed} | ${packetTotals.blocked} | ${packetTotals["not-reached"]} | ${reports.length} |\n| Cases | ${caseTotals.passed} | ${caseTotals.failed} | ${caseTotals.blocked} | ${caseTotals["not-reached"]} | ${Object.values(caseTotals).reduce((sum, count) => sum + count, 0)} |\n| Widget checks | ${widgetTotals.passed} | ${widgetTotals.failed} | ${widgetTotals.blocked} | ${widgetTotals["not-reached"]} | ${Object.values(widgetTotals).reduce((sum, count) => sum + count, 0)} |\n\n| Wave | Passed | Total |\n| --- | ---: | ---: |\n${waveRows.join("\n")}\n\n## Severity totals\n\n| P0 | P1 | P2 | P3 |\n| ---: | ---: | ---: | ---: |\n| ${severityTotals.P0} | ${severityTotals.P1} | ${severityTotals.P2} | ${severityTotals.P3} |\n\nDeduplicated by explicit fingerprint, or by severity + area + title when no fingerprint is supplied.\n\n| Severity | Area | Finding | Packets | Cases |\n| --- | --- | --- | --- | --- |\n${findingRows}\n\n## Critical gaps\n\n| Packet | Kind | Coverage item | Status / detail |\n| --- | --- | --- | --- |\n${gapRows}\n\n## PR coverage\n\n| PR | Area | Packets |\n| --- | --- | ---: |\n${prRows}\n\n## Packet reports\n\n${reports.map(({ packet, metadata }) => `- [${packet.id}: ${packet.title}](packets/${packet.id}/report.md) — ${metadata.status}`).join("\n")}\n`;
const enrichedMaster = master
.replace("## Severity totals", `${coverageAppendix}## Severity totals`)
.replaceAll("(packets/", "(reports/")
.replace("## Packet reports", "## Agent reports");
await writeSafeReportFile(qaRoot, masterReportPath, enrichedMaster, "release-v2 QA master report path");
console.log(
`release-v2 QA report: ${decision}; ${reports.length} packets; ${findings.length} deduplicated findings; ${criticalGaps.length} critical gaps`,
);
const metadataErrors = reports.flatMap((report) => report.metadataErrors);
if (metadataErrors.length > 0) {
console.error(`release-v2 QA report found ${metadataErrors.length} metadata error(s):`);
for (const error of metadataErrors) console.error(`- ${error}`);
process.exitCode = 1;
}
};
await main();
+739
View File
@@ -0,0 +1,739 @@
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 { createServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
acquireSlotLease,
assertSafeContainedPath,
assertSafeRunRoot,
assertSupportedRuntimePlatform,
createFixtureEnvironment,
sanitizeAppEnvironment,
} from "./safety.mts";
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 profiles = ["main-writable", "main-readonly", "onboarding-fresh", "degraded"] as const;
type Profile = (typeof profiles)[number];
export type RuntimeCommand = "start" | "status" | "stop";
export const assertRuntimeCommandPlatform = (command: RuntimeCommand, platform: NodeJS.Platform = process.platform) => {
if (command === "status") return;
assertSupportedRuntimePlatform(platform);
};
interface Options {
command: RuntimeCommand;
slot: number;
profile: Profile;
runRoot: string;
candidate: string;
reset: boolean;
}
interface RuntimeManifest {
schemaVersion: 1;
runId: string;
status: "running" | "stopped";
candidateSha: string;
branch: string | null;
slot: number;
profile: Profile;
startedAt: string;
stoppedAt?: string;
repoRoot: string;
runRoot: string;
slotDir: string;
dbPath: string;
fixtureManifestPath: string;
nextDistDir: string;
url: string;
fixtureUrl: string;
ports: { app: number; fixture: number; redis: number };
flags: { demoMode: boolean; demoReadOnly: boolean; unsafeMockIntegration: boolean };
watcher: { watchpackPollingIntervalMs: number };
processes: {
app: { pid: number; logPath: string };
fixture: { pid: number; logPath: string };
};
docker: { redisContainer: string };
}
const usage = () => {
console.log(
"Usage: runtime.mts <start|status|stop> --slot <1|2|3> [--profile <main-writable|main-readonly|onboarding-fresh|degraded>] [--run-root <path>] [--candidate <ref>] [--reset]",
);
};
const parseArgs = (): Options => {
const args = process.argv.slice(2).filter((argument) => argument !== "--");
if (args[0] === "--help" || args[0] === "-h") {
usage();
process.exit(0);
}
const command = args.shift();
if (command !== "start" && command !== "status" && command !== "stop") {
usage();
throw new Error("A runtime command is required");
}
let slot = 0;
let profile: Profile = "main-writable";
let runRoot = defaultRunRoot;
let candidate = "HEAD";
let reset = false;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
const value = args[index + 1];
if (argument === "--slot" && value) {
slot = Number(value);
index += 1;
continue;
}
if (argument === "--profile" && value && profiles.includes(value as Profile)) {
profile = value as Profile;
index += 1;
continue;
}
if (argument === "--run-root" && value) {
runRoot = value;
index += 1;
continue;
}
if (argument === "--candidate" && value) {
candidate = value;
index += 1;
continue;
}
if (argument === "--reset") {
reset = true;
continue;
}
if (argument === "--help" || argument === "-h") {
usage();
process.exit(0);
}
throw new Error(`Unknown or incomplete argument: ${argument ?? "<missing>"}`);
}
if (![1, 2, 3].includes(slot)) throw new Error("--slot must be 1, 2, or 3");
return { command, slot, profile, runRoot: path.resolve(runRoot), candidate, reset };
};
const commandOutput = (command: string, args: string[]) => {
const result = spawnSync(command, args, { cwd: repoRoot, encoding: "utf8" });
if (result.status !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status ?? "unknown"}`;
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
}
return result.stdout.trim();
};
const resolveCandidate = (candidate: string) =>
commandOutput("git", ["rev-parse", "--verify", `${candidate}^{commit}`]).toLowerCase();
const currentBranch = () => {
const result = spawnSync("git", ["branch", "--show-current"], { cwd: repoRoot, encoding: "utf8" });
const branch = result.stdout.trim();
return branch.length > 0 ? branch : null;
};
const writeJsonAtomic = async (filePath: string, value: unknown) => {
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
const temporaryPath = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
const handle = await open(
temporaryPath,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
0o600,
);
try {
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true });
throw error;
}
};
const assertRunRootMarker = async (runRoot: string) => {
const markerPath = path.join(runRoot, markerName);
await assertSafeContainedPath(runRoot, markerPath, "QA run-root marker");
const marker = JSON.parse(await readFile(markerPath, "utf8")) as {
schemaVersion?: number;
repoRoot?: string;
runRoot?: string;
};
if (marker.schemaVersion !== 1 || path.resolve(marker.repoRoot ?? "") !== repoRoot) {
throw new Error(`QA run-root marker does not belong to this checkout: ${markerPath}`);
}
if (marker.runRoot && path.resolve(marker.runRoot) !== runRoot) {
throw new Error(`QA run-root marker records a different root: ${markerPath}`);
}
};
const readManifest = async (manifestPath: string) => {
const value = JSON.parse(await readFile(manifestPath, "utf8")) as RuntimeManifest;
if (value.schemaVersion !== 1) throw new Error(`Unsupported runtime manifest: ${manifestPath}`);
return value;
};
export interface PortReservation {
port: number;
release: () => Promise<void>;
}
export const reserveLoopbackPort = async (): Promise<PortReservation> =>
await new Promise<PortReservation>((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Unable to allocate a TCP port"));
return;
}
let released = false;
resolve({
port: address.port,
release: async () => {
if (released) return;
released = true;
await new Promise<void>((closeResolve, closeReject) => {
server.close((error) => {
if (error) closeReject(error);
else closeResolve();
});
});
},
});
});
});
type PortLaunchResult<T> = { status: "ready"; value: T } | { status: "address-in-use" };
export const launchWithPortRetry = async <T,>(
launch: (port: number) => Promise<PortLaunchResult<T>>,
options: { attempts?: number; initialReservation?: PortReservation } = {},
): Promise<T> => {
const attempts = options.attempts ?? 3;
let reservation = options.initialReservation;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
reservation ??= await reserveLoopbackPort();
const port = reservation.port;
await reservation.release();
reservation = undefined;
const result = await launch(port);
if (result.status === "ready") return result.value;
}
throw new Error(`Unable to launch the QA app after ${attempts} address-in-use retries`);
};
const waitForJsonFile = async <T,>(filePath: string, timeoutMs: number) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
return JSON.parse(await readFile(filePath, "utf8")) as T;
} catch {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
throw new Error(`Timed out waiting for ${filePath}`);
};
const waitForUrl = async (url: string, timeoutMs: number) => {
const deadline = Date.now() + timeoutMs;
let lastStatus: number | undefined;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5_000) });
lastStatus = response.status;
if (response.ok) return;
} catch {
// Startup failures are reported from the process log after the deadline.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for ${url}; last status=${lastStatus ?? "unreachable"}`);
};
interface SpawnedProcess {
pid: number;
exited: Promise<number | null>;
}
const spawnLogged = (command: string, args: string[], logPath: string, env: NodeJS.ProcessEnv): 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,
detached: true,
env,
stdio: ["ignore", output, output],
});
closeSync(output);
if (!child.pid) throw new Error(`Failed to launch ${command}`);
const exited = new Promise<number | null>((resolve) => {
child.once("exit", (code) => resolve(code));
});
child.unref();
return { pid: child.pid, exited };
};
const runForeground = (command: string, args: string[], env: NodeJS.ProcessEnv) => {
const result = spawnSync(command, args, { cwd: repoRoot, env, stdio: "inherit" });
if (result.status !== 0)
throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}`);
};
const isProcessRunning = (pid: number) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const waitForAppAttempt = async (
spawned: SpawnedProcess,
url: string,
logPath: string,
logOffset: number,
timeoutMs: number,
): Promise<"ready" | "address-in-use"> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const exitCode = await Promise.race([
spawned.exited,
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 250)),
]);
if (exitCode !== undefined) {
const log = await readFile(logPath, "utf8").catch(() => "");
const attemptLog = log.slice(logOffset);
if (/EADDRINUSE|address already in use/iu.test(attemptLog)) return "address-in-use";
throw new Error(`QA app process exited during startup; inspect ${logPath}`);
}
try {
const response = await fetch(`${url}/api/health/ready`, {
redirect: "manual",
signal: AbortSignal.timeout(5_000),
});
if (response.ok && isProcessRunning(spawned.pid)) {
await new Promise((resolve) => setTimeout(resolve, 250));
if (isProcessRunning(spawned.pid)) return "ready";
}
} catch {
// Continue while the selected process owns its startup attempt.
}
}
throw new Error(`Timed out waiting for ${url}; inspect ${logPath}`);
};
const processBelongsToRun = async (pid: number, runId: string) => {
if (!isProcessRunning(pid)) return false;
try {
const environment = await readFile(`/proc/${pid}/environ`);
return environment.includes(Buffer.from(`QA_RUN_ID=${runId}\0`));
} catch {
return false;
}
};
const stopProcess = async (pid: number, runId: string) => {
if (!isProcessRunning(pid)) return;
if (!(await processBelongsToRun(pid, runId))) {
throw new Error(`Refusing to signal PID ${pid}: it is not owned by QA run ${runId}`);
}
try {
process.kill(-pid, "SIGTERM");
} catch {
process.kill(pid, "SIGTERM");
}
const deadline = Date.now() + 10_000;
while (Date.now() < deadline && isProcessRunning(pid)) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (isProcessRunning(pid)) {
try {
process.kill(-pid, "SIGKILL");
} catch {
process.kill(pid, "SIGKILL");
}
}
};
const removeRedisContainer = (container: string, runId: string) => {
const inspect = spawnSync(
"docker",
["inspect", "--format", '{{ index .Config.Labels "com.homarr.release-v2-qa.run" }}', container],
{
encoding: "utf8",
},
);
if (inspect.status !== 0) return;
if (inspect.stdout.trim() !== runId)
throw new Error(`Refusing to remove Docker container without matching QA run label: ${container}`);
const result = spawnSync("docker", ["rm", "--force", container], { stdio: "inherit" });
if (result.status !== 0) throw new Error(`Failed to remove QA Redis container ${container}`);
};
const stopFromManifest = async (
manifestPath: string,
manifest: RuntimeManifest,
expectedRunRoot: string,
expectedSlot: number,
) => {
if (manifest.status === "stopped") return;
const expectedSlotDir = path.join(expectedRunRoot, "slots", String(expectedSlot));
const expectedManifestPath = path.join(expectedSlotDir, "runtime-manifest.json");
if (path.resolve(manifest.runRoot) !== expectedRunRoot || manifest.slot !== expectedSlot) {
throw new Error(`Refusing runtime manifest for a different QA run-root or slot: ${manifestPath}`);
}
if (path.resolve(manifestPath) !== expectedManifestPath) {
throw new Error(`Refusing runtime manifest outside the selected slot: ${manifestPath}`);
}
if (path.resolve(manifest.slotDir) !== path.resolve(expectedSlotDir)) {
throw new Error(`Refusing runtime manifest with an unsafe slot directory: ${manifest.slotDir}`);
}
await assertSafeContainedPath(expectedRunRoot, expectedSlotDir, "QA slot directory");
await stopProcess(manifest.processes.app.pid, manifest.runId);
await stopProcess(manifest.processes.fixture.pid, manifest.runId);
removeRedisContainer(manifest.docker.redisContainer, manifest.runId);
const stopped: RuntimeManifest = { ...manifest, status: "stopped", stoppedAt: new Date().toISOString() };
await assertSafeContainedPath(expectedRunRoot, manifestPath, "QA runtime manifest");
await writeJsonAtomic(manifestPath, stopped);
};
const profileFlags = (profile: Profile) => ({
demoMode: profile !== "onboarding-fresh",
demoReadOnly: profile === "main-readonly",
unsafeMockIntegration: true,
});
const startWithLease = async (options: Options) => {
const candidateSha = resolveCandidate(options.candidate);
const checkoutSha = resolveCandidate("HEAD");
if (checkoutSha !== candidateSha) {
throw new Error(`The requested candidate ${candidateSha} does not match the checked-out HEAD ${checkoutSha}`);
}
const branch = currentBranch();
await mkdir(options.runRoot, { recursive: true, mode: 0o700 });
await assertSafeRunRoot(options.runRoot);
await chmod(options.runRoot, 0o700);
await assertSafeRunRoot(options.runRoot);
const markerPath = path.join(options.runRoot, markerName);
await assertSafeContainedPath(options.runRoot, markerPath, "QA run-root marker");
if (!existsSync(markerPath)) {
await assertSafeContainedPath(options.runRoot, markerPath, "QA run-root marker");
await writeJsonAtomic(markerPath, {
schemaVersion: 1,
repoRoot,
runRoot: options.runRoot,
createdAt: new Date().toISOString(),
});
} else {
await assertRunRootMarker(options.runRoot);
}
const slotsRoot = path.join(options.runRoot, "slots");
await assertSafeContainedPath(options.runRoot, slotsRoot, "QA slots directory");
await mkdir(slotsRoot, { recursive: true, mode: 0o700 });
await assertSafeContainedPath(options.runRoot, slotsRoot, "QA slots directory");
const slotDir = path.join(slotsRoot, String(options.slot));
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot directory");
const manifestPath = path.join(slotDir, "runtime-manifest.json");
if (existsSync(manifestPath)) {
await assertSafeContainedPath(options.runRoot, manifestPath, "QA runtime manifest");
const existing = await readManifest(manifestPath);
if (!options.reset) throw new Error(`Slot ${options.slot} already has a manifest; pass --reset to replace it`);
await stopFromManifest(manifestPath, existing, options.runRoot, options.slot);
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot reset target");
await rm(slotDir, { recursive: true });
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot reset target");
} else if (existsSync(slotDir)) {
if (!options.reset) throw new Error(`Slot ${options.slot} has incomplete state; pass --reset to replace it`);
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot reset target");
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}`);
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(options.runRoot, slotDir, "QA slot creation target");
await mkdir(slotDir, { mode: 0o700 });
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot directory");
await chmod(slotDir, 0o700);
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot directory");
const runId = `release-v2-${candidateSha.slice(0, 8)}-s${options.slot}-${Date.now()}`;
const redisContainer = `homarr-release-v2-qa-redis-s${options.slot}-${candidateSha.slice(0, 8)}-${Date.now().toString(36)}`;
const dbPath = path.join(slotDir, "db.sqlite");
const fixtureReadyPath = path.join(slotDir, "fixture-ready.json");
const fixtureManifestPath = path.join(slotDir, "fixture-manifest.json");
const fixtureLogPath = path.join(slotDir, "fixture.log");
const appLogPath = path.join(slotDir, "app.log");
const flags = profileFlags(options.profile);
let fixturePid: number | undefined;
let appPid: number | undefined;
let appPortReservation: PortReservation | undefined;
let redisCreated = false;
try {
const redisRun = spawnSync(
"docker",
[
"run",
"--detach",
"--name",
redisContainer,
"--label",
`com.homarr.release-v2-qa.run=${runId}`,
"--publish",
"127.0.0.1::6379",
"redis:latest",
],
{ encoding: "utf8" },
);
if (redisRun.status !== 0) throw new Error(`Unable to start QA Redis: ${redisRun.stderr.trim()}`);
redisCreated = true;
const redisPortOutput = commandOutput("docker", ["port", redisContainer, "6379/tcp"]);
const redisPort = Number(redisPortOutput.split(":").at(-1));
if (!Number.isInteger(redisPort)) throw new Error(`Unable to parse QA Redis port: ${redisPortOutput}`);
await assertSafeContainedPath(options.runRoot, fixtureReadyPath, "QA fixture readiness file");
await assertSafeContainedPath(options.runRoot, fixtureLogPath, "QA fixture log");
const fixtureProcess = spawnLogged(
process.execPath,
["--import", "tsx", "scripts/release-v2-qa/fixture-server.mts", "--port", "0", "--ready-file", fixtureReadyPath],
fixtureLogPath,
createFixtureEnvironment(runId),
);
fixturePid = fixtureProcess.pid;
const fixtureReady = await waitForJsonFile<{ url: string; port: number }>(fixtureReadyPath, 30_000);
await waitForUrl(`${fixtureReady.url}/health`, 30_000);
appPortReservation = await reserveLoopbackPort();
let appPort = appPortReservation.port;
let url = `http://127.0.0.1:${appPort}`;
let qaPassword = process.env.QA_PASSWORD;
if (!qaPassword && process.env.QA_PASSWORD_FILE) {
qaPassword = (await readFile(path.resolve(process.env.QA_PASSWORD_FILE), "utf8")).trim();
}
if (flags.demoMode && !qaPassword) {
throw new Error("QA_PASSWORD or QA_PASSWORD_FILE is required for populated QA profiles");
}
const environment: NodeJS.ProcessEnv = {
...process.env,
AUTH_SECRET: randomBytes(32).toString("hex"),
AUTH_PROVIDERS: "credentials",
AUTH_COOKIE_PREFIX: `homarr_qa_s${options.slot}`,
DB_DRIVER: "better-sqlite3",
DB_URL: dbPath,
DEMO_MODE: String(flags.demoMode),
DEMO_READ_ONLY: String(flags.demoReadOnly),
UNSAFE_ENABLE_MOCK_INTEGRATION: String(flags.unsafeMockIntegration),
SECRET_ENCRYPTION_KEY: randomBytes(32).toString("hex"),
REDIS_IS_EXTERNAL: "true",
REDIS_HOST: "127.0.0.1",
REDIS_PORT: String(redisPort),
REDIS_DATABASE_INDEX: String(options.slot),
QA_CANDIDATE_SHA: candidateSha,
QA_FIXTURE_URL: fixtureReady.url,
QA_PROFILE: options.profile,
QA_RUN_ID: runId,
QA_RUN_ROOT: options.runRoot,
QA_SLOT: String(options.slot),
QA_PASSWORD: qaPassword,
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_VERSION: candidateSha.slice(0, 12),
NEXT_TELEMETRY_DISABLED: "1",
};
await assertSafeContainedPath(options.runRoot, dbPath, "QA SQLite database");
runForeground("pnpm", ["db:migration:sqlite:run"], environment);
await assertSafeContainedPath(options.runRoot, dbPath, "QA SQLite database");
await assertSafeContainedPath(options.runRoot, fixtureManifestPath, "QA fixture manifest");
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),
};
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}`;
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 logOffset = await stat(appLogPath)
.then((stats) => stats.size)
.catch(() => 0);
await assertSafeContainedPath(options.runRoot, appLogPath, "QA app log");
const spawned = spawnLogged(appCommand, appArguments, appLogPath, {
...appEnvironment,
HOMARR_WEBSITE_URL: url,
});
appPid = spawned.pid;
const outcome = await waitForAppAttempt(spawned, url, appLogPath, logOffset, 300_000);
if (outcome === "address-in-use") {
appPid = undefined;
return { status: "address-in-use" } as const;
}
return { status: "ready", value: { pid: spawned.pid, port: appPort, url } } as const;
},
{ initialReservation: appPortReservation },
);
appPortReservation = undefined;
appPid = launchedApp.pid;
appPort = launchedApp.port;
url = launchedApp.url;
const manifest: RuntimeManifest = {
schemaVersion: 1,
runId,
status: "running",
candidateSha,
branch,
slot: options.slot,
profile: options.profile,
startedAt: new Date().toISOString(),
repoRoot,
runRoot: options.runRoot,
slotDir,
dbPath,
fixtureManifestPath,
nextDistDir,
url,
fixtureUrl: fixtureReady.url,
ports: { app: appPort, fixture: fixtureReady.port, redis: redisPort },
flags,
watcher: { watchpackPollingIntervalMs },
processes: {
app: { pid: appPid, logPath: appLogPath },
fixture: { pid: fixturePid, logPath: fixtureLogPath },
},
docker: { redisContainer },
};
await writeJsonAtomic(manifestPath, manifest);
console.log(JSON.stringify(manifest, null, 2));
} catch (error) {
if (appPortReservation) await appPortReservation.release();
if (appPid) await stopProcess(appPid, runId);
if (fixturePid) await stopProcess(fixturePid, runId);
if (redisCreated) removeRedisContainer(redisContainer, runId);
throw error;
}
};
const start = async (options: Options) => {
options.runRoot = await assertSafeRunRoot(options.runRoot, { allowMissing: true });
const lease = await acquireSlotLease(options.runRoot, options.slot, `start:${options.profile}`);
try {
await startWithLease(options);
} finally {
await lease.release();
}
};
const status = async (options: Options) => {
options.runRoot = await assertSafeRunRoot(options.runRoot);
await assertRunRootMarker(options.runRoot);
const manifestPath = path.join(options.runRoot, "slots", String(options.slot), "runtime-manifest.json");
await assertSafeContainedPath(options.runRoot, manifestPath, "QA runtime manifest");
const manifest = await readManifest(manifestPath);
const appReachable = await fetch(`${manifest.url}/api/health/live`, { signal: AbortSignal.timeout(5_000) })
.then((response) => response.ok)
.catch(() => false);
const fixtureReachable = await fetch(`${manifest.fixtureUrl}/health`, { signal: AbortSignal.timeout(5_000) })
.then((response) => response.ok)
.catch(() => false);
const redisRunning =
spawnSync("docker", ["inspect", "--format", "{{.State.Running}}", manifest.docker.redisContainer], {
encoding: "utf8",
}).stdout.trim() === "true";
console.log(
JSON.stringify(
{
...manifest,
observed: {
appProcess: await processBelongsToRun(manifest.processes.app.pid, manifest.runId),
fixtureProcess: await processBelongsToRun(manifest.processes.fixture.pid, manifest.runId),
appReachable,
fixtureReachable,
redisRunning,
},
},
null,
2,
),
);
};
const stopWithLease = async (options: Options) => {
const markerPath = path.join(options.runRoot, markerName);
await assertSafeContainedPath(options.runRoot, markerPath, "QA run-root marker");
if (!existsSync(markerPath)) throw new Error(`QA run-root marker is missing: ${markerPath}`);
await assertRunRootMarker(options.runRoot);
const manifestPath = path.join(options.runRoot, "slots", String(options.slot), "runtime-manifest.json");
await assertSafeContainedPath(options.runRoot, manifestPath, "QA runtime manifest");
const manifest = await readManifest(manifestPath);
await stopFromManifest(manifestPath, manifest, options.runRoot, options.slot);
console.log(`Stopped release-v2 QA slot ${options.slot}`);
};
const stop = async (options: Options) => {
options.runRoot = await assertSafeRunRoot(options.runRoot);
const lease = await acquireSlotLease(options.runRoot, options.slot, "stop");
try {
await stopWithLease(options);
} finally {
await lease.release();
}
};
const main = async () => {
const options = parseArgs();
assertRuntimeCommandPlatform(options.command);
if (options.command === "start") await start(options);
else if (options.command === "status") await status(options);
else await stop(options);
};
const isDirectExecution = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href === pathToFileURL(fileURLToPath(import.meta.url)).href
: false;
if (isDirectExecution) await main();
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment node
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";
const openServers: Server[] = [];
const listen = async (server: Server, port: number) =>
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolve);
});
const close = async (server: Server) =>
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
afterEach(async () => {
await Promise.all(
openServers.splice(0).map(async (server) => {
if (server.listening) await close(server);
}),
);
});
describe("release-v2 QA runtime port startup", () => {
test("keeps an allocated loopback port reserved until startup is ready to bind", async () => {
const reservation = await reserveLoopbackPort();
const contender = createServer();
openServers.push(contender);
await expect(listen(contender, reservation.port)).rejects.toMatchObject({ code: "EADDRINUSE" });
await reservation.release();
await listen(contender, reservation.port);
});
test("retries with a newly reserved port after an address-in-use collision", async () => {
let attempts = 0;
const selectedPort = await launchWithPortRetry(async (port) => {
attempts += 1;
const server = createServer();
openServers.push(server);
await listen(server, port);
if (attempts === 1) {
const collidedProcess = createServer();
openServers.push(collidedProcess);
await expect(listen(collidedProcess, port)).rejects.toMatchObject({ code: "EADDRINUSE" });
await close(server);
return { status: "address-in-use" } as const;
}
await close(server);
return { status: "ready", value: port } as const;
});
expect(attempts).toBe(2);
expect(selectedPort).toBeGreaterThan(0);
});
});
describe("release-v2 QA runtime platform boundary", () => {
test.each(["start", "stop"] as const)("rejects %s before dispatch on an unsupported platform", (command) => {
expect(() => assertRuntimeCommandPlatform(command, "darwin")).toThrow(/Linux/u);
});
test("allows read-only status on an unsupported platform", () => {
expect(() => assertRuntimeCommandPlatform("status", "win32")).not.toThrow();
});
test.each(["start", "status", "stop"] as const)("allows %s on Linux", (command) => {
expect(() => assertRuntimeCommandPlatform(command, "linux")).not.toThrow();
});
});
+349
View File
@@ -0,0 +1,349 @@
import { randomUUID } from "node:crypto";
import { lstat, open, readFile, realpath, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const runRootNamePattern = /^homarr-release-v2-qa(?:$|-[A-Za-z0-9][A-Za-z0-9._-]*)$/u;
const zoomOutputNamePattern = /^homarr-release-v2-qa-zoom-[A-Za-z0-9][A-Za-z0-9._-]*$/u;
const ipv6LoopbackHosts = new Set(["::1", "0:0:0:0:0:0:0:1"]);
const leaseSchemaVersion = 1;
const incompleteLeaseGraceMs = 30_000;
const missingPath = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT";
const existingPath = (error: unknown) => (error as NodeJS.ErrnoException).code === "EEXIST";
export const assertSupportedRuntimePlatform = (platform: NodeJS.Platform = process.platform) => {
if (platform !== "linux") {
throw new Error(
"Release-v2 QA runtime mutations are supported only on Linux because safe process ownership and cleanup require procfs and POSIX process groups",
);
}
};
export const isPathWithin = (parent: string, child: string) => {
if (!path.isAbsolute(parent) || !path.isAbsolute(child)) return false;
const relativePath = path.relative(path.resolve(parent), path.resolve(child));
return relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
};
const canonicalTemporaryParent = async () => await realpath(path.resolve(tmpdir()));
const assertSafeTemporaryChild = async (
target: string,
options: {
allowMissing?: boolean;
label: string;
namePattern: RegExp;
nameRequirement: string;
},
) => {
const resolvedTarget = path.resolve(target);
const canonicalParent = await canonicalTemporaryParent();
const targetParent = path.dirname(resolvedTarget);
let realTargetParent: string;
try {
realTargetParent = await realpath(targetParent);
} catch (error) {
if (missingPath(error)) {
throw new Error(`${options.label} must be a direct child of the operating system temporary directory`, {
cause: error,
});
}
throw error;
}
if (realTargetParent !== canonicalParent) {
throw new Error(`${options.label} must be a direct child of the operating system temporary directory`);
}
const targetName = path.basename(resolvedTarget);
if (!options.namePattern.test(targetName)) {
throw new Error(options.nameRequirement);
}
const canonicalTarget = path.join(canonicalParent, targetName);
try {
const stats = await lstat(canonicalTarget);
if (stats.isSymbolicLink()) throw new Error(`${options.label} must not be a symlink`);
if (!stats.isDirectory()) throw new Error(`${options.label} must be a directory`);
if ((await realpath(canonicalTarget)) !== canonicalTarget) {
throw new Error(`${options.label} must not contain symlink components`);
}
} catch (error) {
if (missingPath(error) && options.allowMissing) return canonicalTarget;
throw error;
}
return canonicalTarget;
};
export const assertSafeContainedPath = async (parent: string, child: string, label: string) => {
const resolvedParent = path.resolve(parent);
const resolvedChild = path.resolve(child);
if (!isPathWithin(resolvedParent, resolvedChild)) {
throw new Error(`${label} escapes its approved parent`);
}
const realParent = await realpath(resolvedParent);
if (realParent !== resolvedParent) {
throw new Error(`${label} parent contains a symlink`);
}
const relativePath = path.relative(resolvedParent, resolvedChild);
let currentPath = resolvedParent;
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
currentPath = path.join(currentPath, segment);
try {
const stats = await lstat(currentPath);
if (stats.isSymbolicLink()) throw new Error(`${label} contains a symlink`);
} catch (error) {
if (missingPath(error)) break;
throw error;
}
}
return resolvedChild;
};
export const assertSafeRunRoot = async (runRoot: string, options: { allowMissing?: boolean } = {}) =>
await assertSafeTemporaryChild(runRoot, {
allowMissing: options.allowMissing,
label: "The QA run-root",
namePattern: runRootNamePattern,
nameRequirement:
'The QA run-root basename must start with "homarr-release-v2-qa" and use only safe suffix characters',
});
export const assertSafeZoomOutput = async (outputDirectory: string, options: { allowMissing?: boolean } = {}) =>
await assertSafeTemporaryChild(outputDirectory, {
allowMissing: options.allowMissing,
label: "The zoom extension output",
namePattern: zoomOutputNamePattern,
nameRequirement:
'The zoom extension output basename must start with "homarr-release-v2-qa-zoom-" and use only safe suffix characters',
});
const readLinuxProcessStartMarker = async (pid: number) => {
if (process.platform !== "linux") return null;
try {
const processStat = await readFile(`/proc/${pid}/stat`, "utf8");
const commandEnd = processStat.lastIndexOf(")");
if (commandEnd < 0) return null;
const fieldsAfterCommand = processStat
.slice(commandEnd + 2)
.trim()
.split(/\s+/u);
const startTime = fieldsAfterCommand[19];
return startTime ? `linux:${startTime}` : null;
} catch {
return null;
}
};
const isProcessRunning = (pid: number) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
interface SlotLeaseOwner {
schemaVersion: 1;
pid: number;
processStartMarker: string | null;
token: string;
runRoot: string;
slot: number;
operation: string;
acquiredAt: string;
}
interface LeaseSnapshot {
device: bigint;
inode: bigint;
modifiedMs: number;
content: string;
owner: SlotLeaseOwner | null;
}
export interface SlotLease {
path: string;
owner: SlotLeaseOwner;
release: () => Promise<void>;
}
export const getSlotLeasePath = async (runRoot: string, slot: number) => {
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
const canonicalParent = await canonicalTemporaryParent();
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.slot-${slot}.lease`);
};
const readLeaseSnapshot = async (leasePath: string): Promise<LeaseSnapshot> => {
const stats = await lstat(leasePath, { bigint: true });
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error(`Refusing unsafe QA slot lease path: ${leasePath}`);
}
const content = await readFile(leasePath, "utf8");
let owner: SlotLeaseOwner | null = null;
try {
owner = JSON.parse(content) as SlotLeaseOwner;
} catch {
// An acquiring process may have created the file but not completed its marker write yet.
}
return {
device: stats.dev,
inode: stats.ino,
modifiedMs: Number(stats.mtimeMs),
content,
owner,
};
};
const isValidLeaseOwner = (owner: SlotLeaseOwner | null, runRoot: string, slot: number): owner is SlotLeaseOwner =>
owner?.schemaVersion === leaseSchemaVersion &&
Number.isInteger(owner.pid) &&
owner.pid > 0 &&
typeof owner.token === "string" &&
owner.token.length > 0 &&
path.resolve(owner.runRoot) === runRoot &&
owner.slot === slot;
const processMatchesLeaseOwner = async (owner: SlotLeaseOwner) => {
if (!isProcessRunning(owner.pid)) return false;
const observedStartMarker = await readLinuxProcessStartMarker(owner.pid);
if (owner.processStartMarker && observedStartMarker) {
return owner.processStartMarker === observedStartMarker;
}
// If the platform cannot verify process start identity, preserve the live lock rather than
// risking recovery over an active owner.
return true;
};
const sameLeaseSnapshot = (left: LeaseSnapshot, right: LeaseSnapshot) =>
left.device === right.device &&
left.inode === right.inode &&
left.modifiedMs === right.modifiedMs &&
left.content === right.content;
const recoverStaleLease = async (leasePath: string, snapshot: LeaseSnapshot) => {
const confirmation = await readLeaseSnapshot(leasePath);
if (!sameLeaseSnapshot(snapshot, confirmation)) {
throw new Error(`QA slot lease changed during stale recovery: ${leasePath}`);
}
await unlink(leasePath);
};
export const acquireSlotLease = async (
runRoot: string,
slot: number,
operation: 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,
processStartMarker: await readLinuxProcessStartMarker(process.pid),
token: randomUUID(),
runRoot: canonicalRunRoot,
slot,
operation,
acquiredAt: new Date().toISOString(),
};
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const handle = await open(leasePath, "wx", 0o600);
try {
await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
await handle.sync();
} finally {
await handle.close();
}
return {
path: leasePath,
owner,
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}`);
}
await recoverStaleLease(leasePath, snapshot);
},
};
} catch (error) {
if (!existingPath(error)) throw error;
const snapshot = await readLeaseSnapshot(leasePath);
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 });
}
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,
});
}
await recoverStaleLease(leasePath, snapshot);
}
}
throw new Error(`Unable to acquire QA slot ${slot} lease after stale recovery`);
};
const normalizedHost = (host: string) => {
const lowerHost = host.toLowerCase();
if (lowerHost.startsWith("[") && lowerHost.endsWith("]")) return lowerHost.slice(1, -1);
return lowerHost;
};
export const validateLoopbackHost = (host: string) => {
const normalized = normalizedHost(host);
if (normalized !== "127.0.0.1" && normalized !== "localhost" && !ipv6LoopbackHosts.has(normalized)) {
throw new Error("The fixture host must be an explicit loopback address");
}
return normalized;
};
export const validateFixtureUrl = (value: string) => {
let fixtureUrl: URL;
try {
fixtureUrl = new URL(value);
} catch {
throw new Error("The fixture URL must be a valid loopback HTTP origin");
}
if (
fixtureUrl.protocol !== "http:" ||
fixtureUrl.username ||
fixtureUrl.password ||
fixtureUrl.pathname !== "/" ||
fixtureUrl.search ||
fixtureUrl.hash
) {
throw new Error("The fixture URL must be a credential-free loopback HTTP origin");
}
try {
validateLoopbackHost(fixtureUrl.hostname);
} catch {
throw new Error("The fixture URL must be a credential-free loopback HTTP origin");
}
return fixtureUrl.origin;
};
export const sanitizeAppEnvironment = (environment: NodeJS.ProcessEnv) => {
const sanitized = { ...environment };
delete sanitized.QA_PASSWORD;
delete sanitized.QA_PASSWORD_FILE;
return sanitized;
};
export const createFixtureEnvironment = (runId: string): NodeJS.ProcessEnv => ({ QA_RUN_ID: runId });
+233
View File
@@ -0,0 +1,233 @@
// @vitest-environment node
import { mkdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
acquireSlotLease,
assertSafeContainedPath,
assertSafeRunRoot,
assertSupportedRuntimePlatform,
createFixtureEnvironment,
getSlotLeasePath,
isPathWithin,
sanitizeAppEnvironment,
validateFixtureUrl,
validateLoopbackHost,
} from "./safety.mts";
const temporaryPaths: string[] = [];
const uniqueName = (suffix: string) => `homarr-release-v2-qa-test-${process.pid}-${Date.now()}-${suffix}`;
afterEach(async () => {
await Promise.all(temporaryPaths.splice(0).map((target) => rm(target, { force: true, recursive: true })));
});
describe("release-v2 QA harness safety", () => {
test("supports runtime mutations only on Linux", () => {
expect(() => assertSupportedRuntimePlatform("linux")).not.toThrow();
expect(() => assertSupportedRuntimePlatform("darwin")).toThrow(/Linux/u);
expect(() => assertSupportedRuntimePlatform("win32")).toThrow(/Linux/u);
});
test("rejects an unsupported platform before creating a slot lease", async () => {
const runRoot = path.join(tmpdir(), uniqueName("unsupported-platform"));
const leasePath = await getSlotLeasePath(runRoot, 1);
temporaryPaths.push(leasePath);
await expect(acquireSlotLease(runRoot, 1, "unsupported-start", "darwin")).rejects.toThrow(/Linux/u);
await expect(stat(leasePath)).rejects.toMatchObject({ code: "ENOENT" });
});
test("accepts only direct, predictably named children of the temporary directory", async () => {
const safeRoot = path.join(tmpdir(), uniqueName("safe"));
temporaryPaths.push(safeRoot);
await expect(assertSafeRunRoot(safeRoot, { allowMissing: true })).resolves.toBe(path.resolve(safeRoot));
await expect(
assertSafeRunRoot(path.join(tmpdir(), "unrelated-homarr-release-v2-qa"), { allowMissing: true }),
).rejects.toThrow("must start with");
await expect(
assertSafeRunRoot(path.join(tmpdir(), "homarr-release-v2-qaevil"), { allowMissing: true }),
).rejects.toThrow("must start with");
await expect(
assertSafeRunRoot(path.join(tmpdir(), "nested", uniqueName("nested")), { allowMissing: true }),
).rejects.toThrow("direct child");
await expect(
assertSafeRunRoot(path.join(tmpdir(), "..", "var", "tmp", uniqueName("traversal")), { allowMissing: true }),
).rejects.toThrow("direct child");
});
test("canonicalizes a temporary-directory alias without widening containment", async () => {
const temporaryAlias = path.join(tmpdir(), uniqueName("temporary-alias"));
const runRootName = uniqueName("canonical-root");
temporaryPaths.push(temporaryAlias);
await symlink(tmpdir(), temporaryAlias, "dir");
await expect(assertSafeRunRoot(path.join(temporaryAlias, runRootName), { allowMissing: true })).resolves.toBe(
path.join(await realpath(tmpdir()), runRootName),
);
});
test("rejects a symlink run-root", async () => {
const target = path.join(tmpdir(), uniqueName("target"));
const linkedRoot = path.join(tmpdir(), uniqueName("linked"));
temporaryPaths.push(linkedRoot, target);
await mkdir(target);
await symlink(target, linkedRoot);
await expect(assertSafeRunRoot(linkedRoot)).rejects.toThrow("symlink");
});
test("rejects symlink components in a contained reset target", async () => {
const runRoot = path.join(tmpdir(), uniqueName("component-root"));
const externalTarget = path.join(tmpdir(), uniqueName("component-target"));
temporaryPaths.push(runRoot, externalTarget);
await mkdir(runRoot);
await mkdir(externalTarget);
await symlink(externalTarget, path.join(runRoot, "slots"));
await expect(
assertSafeContainedPath(runRoot, path.join(runRoot, "slots", "1"), "QA slot reset target"),
).rejects.toThrow("symlink");
});
test("catches a symlink swapped in after an earlier missing-target check", async () => {
const runRoot = path.join(tmpdir(), uniqueName("swap-root"));
const externalTarget = path.join(tmpdir(), uniqueName("swap-target"));
const slotPath = path.join(runRoot, "slots", "1");
temporaryPaths.push(runRoot, externalTarget);
await mkdir(path.dirname(slotPath), { recursive: true });
await mkdir(externalTarget);
await expect(assertSafeContainedPath(runRoot, slotPath, "QA slot mutation target")).resolves.toBe(slotPath);
await symlink(externalTarget, slotPath);
await expect(assertSafeContainedPath(runRoot, slotPath, "QA slot mutation target")).rejects.toThrow("symlink");
});
test("rejects a concurrent operation while a live slot lease is held", async () => {
const runRoot = path.join(tmpdir(), uniqueName("live-lease"));
const lease = await acquireSlotLease(runRoot, 1, "first-start");
temporaryPaths.push(lease.path);
await expect(acquireSlotLease(runRoot, 1, "second-start")).rejects.toThrow(/slot 1 is busy.*first-start/u);
await lease.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);
temporaryPaths.push(leasePath);
await writeFile(
leasePath,
`${JSON.stringify({
schemaVersion: 1,
pid: 2_147_483_647,
processStartMarker: "linux:stale",
token: "stale-token",
runRoot: path.resolve(runRoot),
slot: 2,
operation: "stale-start",
acquiredAt: "2026-01-01T00:00:00.000Z",
})}\n`,
{ mode: 0o600 },
);
const lease = await acquireSlotLease(runRoot, 2, "replacement-start");
expect(lease.owner.operation).toBe("replacement-start");
await lease.release();
});
test("recovers a PID-reuse lease marker without signaling the unrelated process", async () => {
const runRoot = path.join(tmpdir(), uniqueName("pid-reuse-lease"));
const leasePath = await getSlotLeasePath(runRoot, 3);
temporaryPaths.push(leasePath);
await writeFile(
leasePath,
`${JSON.stringify({
schemaVersion: 1,
pid: process.pid,
processStartMarker: "linux:not-this-process",
token: "reused-pid-token",
runRoot: path.resolve(runRoot),
slot: 3,
operation: "abandoned-start",
acquiredAt: "2026-01-01T00:00:00.000Z",
})}\n`,
{ mode: 0o600 },
);
const lease = await acquireSlotLease(runRoot, 3, "safe-recovery");
expect(process.pid).toBeGreaterThan(0);
await lease.release();
});
test("uses path segments rather than string prefixes for containment", () => {
const parent = path.join(tmpdir(), "qa-artifacts", "packet-1");
expect(isPathWithin(parent, path.join(parent, "screen.png"))).toBe(true);
expect(isPathWithin(parent, `${parent}-spoof/screen.png`)).toBe(false);
expect(isPathWithin(parent, path.join(parent, "..", "packet-2", "screen.png"))).toBe(false);
expect(isPathWithin(parent, ".screenshots/release-v2/packet-1/screen.png")).toBe(false);
});
test.each(["http://127.0.0.1:3210", "http://localhost:3210", "http://[::1]:3210", "http://[0:0:0:0:0:0:0:1]:3210"])(
"accepts a credential-free loopback fixture origin: %s",
(fixtureUrl) => {
expect(validateFixtureUrl(fixtureUrl)).toBe(new URL(fixtureUrl).origin);
},
);
test.each([
"https://127.0.0.1:3210",
"http://user:secret@127.0.0.1:3210",
"http://0.0.0.0:3210",
"http://192.0.2.1:3210",
"http://localhost.example:3210",
"http://127.0.0.1:3210/path",
])("rejects an unsafe fixture URL without echoing it: %s", (fixtureUrl) => {
expect(() => validateFixtureUrl(fixtureUrl)).toThrow("fixture URL");
});
test.each(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0:0:0:0:0:1"])(
"accepts the loopback fixture bind host %s",
(host) => {
expect(validateLoopbackHost(host)).toBeTruthy();
},
);
test.each(["0.0.0.0", "::", "192.0.2.1", "example.test"])("rejects the non-loopback bind host %s", (host) => {
expect(() => validateLoopbackHost(host)).toThrow("loopback");
});
test("removes both QA password inputs from the spawned app environment", () => {
const environment = sanitizeAppEnvironment({
QA_PASSWORD: "do-not-forward",
QA_PASSWORD_FILE: "/tmp/do-not-forward",
QA_RUN_ID: "qa-run",
});
expect(environment).toEqual({ QA_RUN_ID: "qa-run" });
expect("QA_PASSWORD" in environment).toBe(false);
expect("QA_PASSWORD_FILE" in environment).toBe(false);
});
test("uses an explicit fixture environment allowlist", () => {
const environment = createFixtureEnvironment("qa-run");
expect(environment).toEqual({ QA_RUN_ID: "qa-run" });
for (const forbiddenName of [
"QA_PASSWORD",
"QA_PASSWORD_FILE",
"DB_URL",
"AUTH_SECRET",
"API_KEY",
"SECRET_ENCRYPTION_KEY",
"HOME",
"PATH",
]) {
expect(forbiddenName in environment).toBe(false);
}
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+172
View File
@@ -0,0 +1,172 @@
import { lstat, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { assertSafeZoomOutput } from "./safety.mts";
const allowedFactorStrings = new Set(["0.8", "1", "1.25", "2"]);
interface ZoomOptions {
origin: string;
factor: number;
outputDirectory: string;
}
const usage = () => {
console.log("Usage: zoom.mts --origin <http(s)-origin> --factor <0.8|1|1.25|2> --output <new-absolute-directory>");
};
const validateOrigin = (value: string) => {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("--origin must be a valid HTTP(S) origin");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("--origin must use http: or https:");
}
if (url.username || url.password) {
throw new Error("--origin must not contain credentials");
}
if (url.pathname !== "/" || url.search || url.hash) {
throw new Error("--origin must not contain a path, query, or fragment");
}
return url.origin;
};
const validateOutputDirectory = async (value: string) => {
if (!path.isAbsolute(value)) {
throw new Error("--output must be an absolute path to a new directory");
}
const outputDirectory = await assertSafeZoomOutput(value, { allowMissing: true });
let outputExists = true;
try {
await lstat(outputDirectory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
outputExists = false;
}
if (outputExists) throw new Error(`--output must not already exist: ${outputDirectory}`);
return outputDirectory;
};
export const parseZoomOptions = async (rawArguments: string[]): Promise<ZoomOptions> => {
const args = rawArguments.filter((argument) => argument !== "--");
let origin: string | undefined;
let factorString: string | undefined;
let output: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
const value = args[index + 1];
if (argument === "--origin" && value) {
origin = value;
index += 1;
continue;
}
if (argument === "--factor" && value) {
factorString = value;
index += 1;
continue;
}
if (argument === "--output" && value) {
output = value;
index += 1;
continue;
}
throw new Error(`Unknown or incomplete argument: ${argument ?? "<missing>"}`);
}
if (!origin) throw new Error("--origin is required");
if (!factorString) throw new Error("--factor is required");
if (!allowedFactorStrings.has(factorString)) {
throw new Error("--factor must be one of 0.8, 1, 1.25, or 2");
}
if (!output) throw new Error("--output is required");
return {
origin: validateOrigin(origin),
factor: Number(factorString),
outputDirectory: await validateOutputDirectory(output),
};
};
export const createZoomExtensionContent = (origin: string, factor: number) => {
const manifest = {
manifest_version: 3,
name: "Homarr release v2 native zoom",
version: "1.0.0",
permissions: ["tabs"],
host_permissions: [`${origin}/*`],
background: { service_worker: "background.js" },
};
const background = `const TARGET_ORIGIN = ${JSON.stringify(origin)};
const ZOOM_FACTOR = ${JSON.stringify(factor)};
function applyZoom(tab) {
if (tab?.id == null || !tab.url) return;
let origin;
try {
origin = new URL(tab.url).origin;
} catch {
return;
}
if (origin !== TARGET_ORIGIN) return;
chrome.tabs.setZoom(tab.id, ZOOM_FACTOR, () => {
void chrome.runtime.lastError;
});
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status !== "complete") return;
applyZoom({ ...tab, id: tabId });
});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(applyZoom);
});
`;
return {
manifest: `${JSON.stringify(manifest, null, 2)}\n`,
background,
};
};
export const generateZoomExtension = async (options: ZoomOptions) => {
const content = createZoomExtensionContent(options.origin, options.factor);
await assertSafeZoomOutput(options.outputDirectory, { allowMissing: true });
await mkdir(options.outputDirectory, { mode: 0o700 });
await assertSafeZoomOutput(options.outputDirectory);
await Promise.all([
writeFile(path.join(options.outputDirectory, "manifest.json"), content.manifest, { flag: "wx", mode: 0o600 }),
writeFile(path.join(options.outputDirectory, "background.js"), content.background, { flag: "wx", mode: 0o600 }),
]);
};
const main = async () => {
const rawArguments = process.argv.slice(2);
if (rawArguments.includes("--help") || rawArguments.includes("-h")) {
usage();
return;
}
const options = await parseZoomOptions(rawArguments);
await generateZoomExtension(options);
console.log(`Generated release-v2 native zoom extension`);
console.log(`Origin: ${options.origin}`);
console.log(`Factor: ${options.factor}`);
console.log(`Output: ${options.outputDirectory}`);
};
const isDirectExecution = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href === pathToFileURL(fileURLToPath(import.meta.url)).href
: false;
if (isDirectExecution) await main();
+155
View File
@@ -0,0 +1,155 @@
// @vitest-environment node
import { mkdir, readFile, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createZoomExtensionContent, generateZoomExtension, parseZoomOptions } from "./zoom.mts";
const temporaryDirectories: string[] = [];
const createOutputPath = (suffix: string) => {
const directory = path.join(
tmpdir(),
`homarr-release-v2-qa-zoom-${process.pid}-${Date.now()}-${suffix}-${temporaryDirectories.length}`,
);
temporaryDirectories.push(directory);
return directory;
};
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
});
describe("release-v2 native zoom helper", () => {
test("generates deterministic, exact-origin MV3 extension files", async () => {
const firstOutput = createOutputPath("first");
const secondOutput = createOutputPath("second");
const firstOptions = await parseZoomOptions([
"--origin",
"http://127.0.0.1:34401/",
"--factor",
"1.25",
"--output",
firstOutput,
]);
const secondOptions = await parseZoomOptions([
"--origin",
"http://127.0.0.1:34401",
"--factor",
"1.25",
"--output",
secondOutput,
]);
await generateZoomExtension(firstOptions);
await generateZoomExtension(secondOptions);
const firstManifest = await readFile(path.join(firstOutput, "manifest.json"), "utf8");
const firstBackground = await readFile(path.join(firstOutput, "background.js"), "utf8");
expect(firstManifest).toBe(await readFile(path.join(secondOutput, "manifest.json"), "utf8"));
expect(firstBackground).toBe(await readFile(path.join(secondOutput, "background.js"), "utf8"));
expect(JSON.parse(firstManifest)).toMatchObject({
manifest_version: 3,
permissions: ["tabs"],
host_permissions: ["http://127.0.0.1:34401/*"],
background: { service_worker: "background.js" },
});
expect(firstBackground).toContain('const TARGET_ORIGIN = "http://127.0.0.1:34401";');
expect(firstBackground).toContain("const ZOOM_FACTOR = 1.25;");
expect(firstBackground).toContain("origin !== TARGET_ORIGIN");
expect(firstBackground).not.toContain("password");
});
test.each(["0.8", "1", "1.25", "2"])("accepts the supported factor %s", async (factor) => {
const output = createOutputPath(`factor-${factor.replace(".", "-")}`);
const options = await parseZoomOptions([
"--origin",
"https://qa.example.test:8443",
"--factor",
factor,
"--output",
output,
]);
expect(options.factor).toBe(Number(factor));
});
test.each(["0", "0.9", "1.0", "1.5", "2.1", "not-a-number"])("rejects unsupported factor %s", async (factor) => {
const output = createOutputPath(`invalid-factor-${factor.replaceAll(/[^A-Za-z0-9]/gu, "-")}`);
await expect(
parseZoomOptions(["--origin", "http://127.0.0.1:34401", "--factor", factor, "--output", output]),
).rejects.toThrow("--factor must be one of 0.8, 1, 1.25, or 2");
});
test.each([
"ftp://127.0.0.1:34401",
"http://user:secret@127.0.0.1:34401",
"http://127.0.0.1:34401/boards/qa-grid-24",
"http://127.0.0.1:34401/?mode=qa",
"not-a-url",
])("rejects a non-origin target without echoing it: %s", async (origin) => {
const output = createOutputPath("invalid-origin");
await expect(parseZoomOptions(["--origin", origin, "--factor", "1", "--output", output])).rejects.toThrow(
/--origin/u,
);
});
test("requires a new, predictably named direct child of the temporary directory", async () => {
const existingDirectory = createOutputPath("existing");
const nestedParent = createOutputPath("nested-parent");
await mkdir(existingDirectory);
await mkdir(nestedParent);
await expect(
parseZoomOptions(["--origin", "http://127.0.0.1:34401", "--factor", "1", "--output", "relative-extension"]),
).rejects.toThrow("--output must be an absolute path");
await expect(
parseZoomOptions(["--origin", "http://127.0.0.1:34401", "--factor", "1", "--output", existingDirectory]),
).rejects.toThrow("--output must not already exist");
await expect(
parseZoomOptions([
"--origin",
"http://127.0.0.1:34401",
"--factor",
"1",
"--output",
path.join(nestedParent, "homarr-release-v2-qa-zoom-nested"),
]),
).rejects.toThrow("direct child");
await expect(
parseZoomOptions([
"--origin",
"http://127.0.0.1:34401",
"--factor",
"1",
"--output",
path.join(tmpdir(), "uncontrolled-extension"),
]),
).rejects.toThrow("basename must start");
});
test("canonicalizes a temporary-directory alias for extension output", async () => {
const temporaryAlias = createOutputPath("alias-parent");
await symlink(tmpdir(), temporaryAlias, "dir");
const outputName = `homarr-release-v2-qa-zoom-${process.pid}-${Date.now()}-through-alias`;
const canonicalOutput = path.join(tmpdir(), outputName);
temporaryDirectories.push(canonicalOutput);
const options = await parseZoomOptions([
"--origin",
"http://127.0.0.1:34401",
"--factor",
"1",
"--output",
path.join(temporaryAlias, outputName),
]);
expect(options.outputDirectory).toBe(canonicalOutput);
});
test("renders baseline factor 1 without changing the extension shape", () => {
const content = createZoomExtensionContent("https://qa.example.test", 1);
expect(content.background).toContain("const ZOOM_FACTOR = 1;");
});
});