diff --git a/package.json b/package.json index edbd669c7..861c470e1 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "lint:fix": "turbo lint --continue -- --fix", "lint:ws": "pnpm dlx sherif@latest", "qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh", + "qa:release-v2:fixtures": "tsx scripts/release-v2-qa/fixture-server.mts", + "qa:release-v2:seed": "tsx scripts/release-v2-qa/seed.mts", "release": "semantic-release", "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", diff --git a/qa/release-v2/README.md b/qa/release-v2/README.md new file mode 100644 index 000000000..878730725 --- /dev/null +++ b/qa/release-v2/README.md @@ -0,0 +1,48 @@ +# Release-v2 mega QA seeder + +This test-only harness creates deterministic users, boards, layouts, containers, integrations, Custom Widgets, and all current widget kinds for release-v2 browser QA. It does not change production migrations or the normal demo seed. + +## Requirements + +- Run the normal database migrations and base seed first. +- Use an isolated SQLite database through `DB_DRIVER=better-sqlite3` and an absolute `DB_URL`. +- Start the fixture service and provide its loopback URL as `QA_FIXTURE_URL`. +- Put the disposable QA password in a file and provide its path through `QA_PASSWORD_FILE`. The password is hashed and never written to the fixture manifest. +- Set the exact profile flags shown below. The seeder rejects missing, malformed, or mismatched values before changing the database. + +## Commands + +Start the deterministic fixture service: + +```bash +pnpm qa:release-v2:fixtures -- --ready-file /tmp/release-v2-fixture-ready.json +``` + +Seed a populated writable fixture: + +```bash +DB_DRIVER=better-sqlite3 \ +DB_URL=/absolute/path/to/qa.sqlite \ +QA_FIXTURE_URL=http://127.0.0.1: \ +QA_PASSWORD_FILE=/absolute/path/to/qa-password \ +DEMO_MODE=true \ +DEMO_READ_ONLY=false \ +UNSAFE_ENABLE_MOCK_INTEGRATION=true \ +pnpm qa:release-v2:seed -- \ + --profile main-writable \ + --output /absolute/path/to/output \ + --reset +``` + +Supported profiles: + +| Profile | `DEMO_MODE` | `DEMO_READ_ONLY` | `UNSAFE_ENABLE_MOCK_INTEGRATION` | +| ------------------ | ----------- | ---------------- | -------------------------------- | +| `main-writable` | `true` | `false` | `true` | +| `main-readonly` | `true` | `true` | `true` | +| `onboarding-fresh` | `false` | `false` | `true` | +| `degraded` | `true` | `false` | `true` | + +`onboarding-fresh` requires no QA password and removes only resources with fixed `qa-v2-*` identifiers. Populated profiles are idempotent. `--reset` remains narrowly scoped to those deterministic identifiers. + +The generated `fixture-manifest.json` records the checkout SHA, profile, non-secret persona login usernames, boards, layouts, item counts, expected permissions, fixture URL, and runtime flags. It never contains a password, password hash, token, encryption key, or Custom Widget secret. diff --git a/scripts/release-v2-qa/fixture-server.mts b/scripts/release-v2-qa/fixture-server.mts new file mode 100644 index 000000000..faab176a8 --- /dev/null +++ b/scripts/release-v2-qa/fixture-server.mts @@ -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 ?? ""}`); + } + + 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", + ` +Release v2 QAhttps://example.invalid/qaDeterministic feedqa-entry-1Alpha release notehttps://example.invalid/qa/alphaThu, 01 Jan 2026 00:00:00 GMTStable fixture entryqa-entry-2Beta release notehttps://example.invalid/qa/betaFri, 02 Jan 2026 00:00:00 GMTSecond stable fixture entry\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", + 'Release v2 QA\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", + 'Release v2 QA iframe

Deterministic iframe

release-v2-qa

\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((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(); diff --git a/scripts/release-v2-qa/geometry.mts b/scripts/release-v2-qa/geometry.mts new file mode 100644 index 000000000..1493ee886 --- /dev/null +++ b/scripts/release-v2-qa/geometry.mts @@ -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(); + + 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(); + 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; +}; diff --git a/scripts/release-v2-qa/geometry.spec.ts b/scripts/release-v2-qa/geometry.spec.ts new file mode 100644 index 000000000..f0dd9c97c --- /dev/null +++ b/scripts/release-v2-qa/geometry.spec.ts @@ -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); + }); +}); diff --git a/scripts/release-v2-qa/provenance.mts b/scripts/release-v2-qa/provenance.mts new file mode 100644 index 000000000..d4fd584ce --- /dev/null +++ b/scripts/release-v2-qa/provenance.mts @@ -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 = { + "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; + +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); +}; diff --git a/scripts/release-v2-qa/provenance.spec.ts b/scripts/release-v2-qa/provenance.spec.ts new file mode 100644 index 000000000..11cb1e23d --- /dev/null +++ b/scripts/release-v2-qa/provenance.spec.ts @@ -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" }); + }); +}); diff --git a/scripts/release-v2-qa/safety.mts b/scripts/release-v2-qa/safety.mts new file mode 100644 index 000000000..19cd1d646 --- /dev/null +++ b/scripts/release-v2-qa/safety.mts @@ -0,0 +1,378 @@ +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; +} + +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`); +}; + +export const getBuildLeasePath = async (runRoot: string) => { + const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true }); + const canonicalParent = await canonicalTemporaryParent(); + return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.candidate-build.lease`); +}; + +const readLeaseSnapshot = async (leasePath: string): Promise => { + 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); +}; + +const acquireLease = async ( + runRoot: string, + slot: number, + operation: string, + leasePath: string, + resourceLabel: string, + platform: NodeJS.Platform = process.platform, +): Promise => { + assertSupportedRuntimePlatform(platform); + const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true }); + 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 ${resourceLabel} 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 ${resourceLabel} lease is being initialized by another operation`, { cause: error }); + } + await recoverStaleLease(leasePath, snapshot); + continue; + } + if (await processMatchesLeaseOwner(existingOwner)) { + throw new Error( + `QA ${resourceLabel} is busy with ${existingOwner.operation} owned by PID ${existingOwner.pid}`, + { + cause: error, + }, + ); + } + await recoverStaleLease(leasePath, snapshot); + } + } + + throw new Error(`Unable to acquire QA ${resourceLabel} lease after stale recovery`); +}; + +export const acquireSlotLease = async ( + runRoot: string, + slot: number, + operation: string, + platform: NodeJS.Platform = process.platform, +) => { + if (![1, 2, 3].includes(slot)) throw new Error("QA slot lease requires slot 1, 2, or 3"); + const leasePath = await getSlotLeasePath(runRoot, slot); + return await acquireLease(runRoot, slot, operation, leasePath, `slot ${slot}`, platform); +}; + +export const acquireBuildLease = async ( + runRoot: string, + operation: string, + platform: NodeJS.Platform = process.platform, +) => { + const leasePath = await getBuildLeasePath(runRoot); + return await acquireLease(runRoot, 0, operation, leasePath, "candidate build", platform); +}; + +const normalizedHost = (host: string) => { + 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 }); diff --git a/scripts/release-v2-qa/safety.spec.ts b/scripts/release-v2-qa/safety.spec.ts new file mode 100644 index 000000000..9fc2de59c --- /dev/null +++ b/scripts/release-v2-qa/safety.spec.ts @@ -0,0 +1,245 @@ +// @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 { + acquireBuildLease, + 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("serializes the shared candidate build independently of slot leases", async () => { + const runRoot = path.join(tmpdir(), uniqueName("build-lease")); + const buildLease = await acquireBuildLease(runRoot, "first-build"); + const slotLease = await acquireSlotLease(runRoot, 1, "slot-start"); + temporaryPaths.push(buildLease.path, slotLease.path); + + await expect(acquireBuildLease(runRoot, "second-build")).rejects.toThrow(/candidate build is busy.*first-build/u); + await buildLease.release(); + await slotLease.release(); + }); + + test("recovers a stale lease with a dead owner PID", async () => { + const runRoot = path.join(tmpdir(), uniqueName("stale-lease")); + const leasePath = await getSlotLeasePath(runRoot, 2); + 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); + } + }); +}); diff --git a/scripts/release-v2-qa/seed.mts b/scripts/release-v2-qa/seed.mts new file mode 100644 index 000000000..5934086cd --- /dev/null +++ b/scripts/release-v2-qa/seed.mts @@ -0,0 +1,1300 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { stringify } from "superjson"; + +import { comparePasswordsAsync, hashPasswordAsync } from "../../packages/auth/security.ts"; +import { customWidgetDefinitionSchema } from "../../packages/custom-widgets/src/core/index.ts"; +import { + emptySuperJSON, + getBoardLaneColumnCount, + getDefaultWidgetConfig, + getRootSectionLane, + getWidgetKindsForIntegration, + widgetKinds, +} from "../../packages/definitions/index.ts"; +import type { BoardPermission, GroupPermissionKey, LayoutRole, WidgetKind } from "../../packages/definitions/index.ts"; +import { db as rootDb, eq, inArray, sql } from "../../packages/db/index.ts"; +import { + apps, + boardGroupPermissions, + boardUserPermissions, + boards, + customWidgetDefinitions, + groupMembers, + groupPermissions, + groups, + iconRepositories, + icons, + integrationItems, + integrations, + itemLayouts, + items, + layouts, + onboarding, + sectionLayouts, + sections, + users, +} from "../../packages/db/schema/index.ts"; +import { boardNameSchema } from "../../packages/validation/src/board.ts"; + +import { findFixtureGeometryErrors } from "./geometry.mts"; +import { + assertReleaseV2QaProfileEnvironment, + releaseV2QaProfiles, + resolveCheckoutCandidateSha, + validateCandidateSha, +} from "./provenance.mts"; +import type { ReleaseV2QaProfile, ReleaseV2QaProfileFlags } from "./provenance.mts"; +import { validateFixtureUrl } from "./safety.mts"; + +let db = rootDb; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +const profiles = releaseV2QaProfiles; +type Profile = ReleaseV2QaProfile; + +interface CliOptions { + candidateSha?: string; + manifestPath?: string; + outputDirectory?: string; + profile?: Profile; + reset: boolean; +} + +interface PersonaDefinition { + handle: string; + name: string; + permissions: GroupPermissionKey[]; +} + +interface LayoutDefinition { + breakpoint: number; + columnCount: number; + handle: string; + leftGutterColumnCount?: number; + name: string; + rightGutterColumnCount?: number; + role: LayoutRole; +} + +interface BoardDefinition { + handle: string; + isPublic?: boolean; + label: string; + layouts?: LayoutDefinition[]; + ownerHandle: string; +} + +interface ItemDefinition { + advancedOptions?: string; + boardHandle: string; + handle: string; + height: number; + kind: WidgetKind; + options: string; + sectionHandle?: string; + width: number; +} + +interface Position { + boardHandle: string; + height: number; + itemHandle: string; + layoutHandle: string; + sectionHandle: string; + width: number; + xOffset: number; + yOffset: number; +} + +const mobileLayout: LayoutDefinition = { + handle: "mobile", + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile", +}; +const defaultLayouts: LayoutDefinition[] = [ + mobileLayout, + { handle: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" }, +]; + +const personas: PersonaDefinition[] = [ + { handle: "avery-admin", name: "Avery Admin", permissions: ["admin"] }, + { handle: "rowan-owner", name: "Rowan Owner", permissions: ["board-create"] }, + { handle: "eden-editor", name: "Eden Editor", permissions: [] }, + { handle: "vivian-viewer", name: "Vivian Viewer", permissions: [] }, + { handle: "nolan-outsider", name: "Nolan Outsider", permissions: [] }, + { handle: "morgan-mobile", name: "Morgan Mobile", permissions: [] }, + { handle: "casey-chaos", name: "Casey Chaos", permissions: [] }, + { handle: "ingrid-infra", name: "Ingrid Infra", permissions: ["integration-full-all"] }, + { handle: "maya-media", name: "Maya Media", permissions: ["media-full-all"] }, + { handle: "brooke-minimalist", name: "Brooke Minimalist", permissions: [] }, + { handle: "cora-creator", name: "Cora Creator", permissions: ["board-create", "app-create"] }, + { handle: "ash-assistant", name: "Ash Assistant", permissions: [] }, + { handle: "kira-keyboard", name: "Kira Keyboard", permissions: [] }, + { handle: "nora-newcomer", name: "Nora Newcomer", permissions: [] }, +]; + +const widgetGalleryGroups: readonly (readonly WidgetKind[])[] = [ + ["clock", "weather", "airQuality", "countdown", "timer"], + ["app", "iframe", "video", "minecraftServerStatus", "stockPrice"], + ["notebook", "anchorNote", "bookmarks", "rssFeed", "timetable"], + ["downloads", "dockerContainers", "indexerManager", "dnsHoleSummary", "dnsHoleControls"], + ["smartHome-entityState", "smartHome-executeAutomation", "healthMonitoring", "systemResources", "systemDisks"], + ["firewall", "notifications", "networkControllerSummary", "networkControllerStatus", "uptimeKuma"], + ["beszelSystemTable", "beszelSystemGrid", "beszelAlerts", "beszelSystemStats", "wud"], + ["ups", "vpn", "speedtestTracker", "traefik", "umami"], + ["calendar", "mediaServer", "mediaRequests-requestList", "mediaRequests-requestStats", "mediaMissing"], + ["mediaReleases", "mediaTranscoding", "immich-serverStats", "immich-albumCarousel", "audioStats"], + ["paperlessNgx", "patchmon", "bazarr", "tracearr", "releases"], + ["coolify", "archiveTeamWarrior", "customApi", "assistant"], +]; + +const sharedBoards: BoardDefinition[] = [ + { + handle: "grid-24", + label: "QA v2 · 24-column grid", + ownerHandle: "avery-admin", + layouts: [ + mobileLayout, + { + handle: "six", + name: "6 columns", + columnCount: 6, + breakpoint: 480, + role: "custom", + leftGutterColumnCount: 1, + rightGutterColumnCount: 1, + }, + { + handle: "twelve", + name: "12 columns", + columnCount: 12, + breakpoint: 768, + role: "custom", + leftGutterColumnCount: 1, + rightGutterColumnCount: 1, + }, + { + handle: "eighteen", + name: "18 columns", + columnCount: 18, + breakpoint: 1024, + role: "custom", + leftGutterColumnCount: 1, + rightGutterColumnCount: 1, + }, + { + handle: "width-coverage", + name: "24-column width coverage", + columnCount: 24, + breakpoint: 1280, + role: "custom", + }, + { + handle: "base", + name: "Base 24", + columnCount: 24, + breakpoint: 1440, + role: "base", + leftGutterColumnCount: 1, + rightGutterColumnCount: 1, + }, + ], + }, + { + handle: "scroll", + label: "QA v2 · Scroll", + ownerHandle: "avery-admin", + layouts: [ + mobileLayout, + { + handle: "base", + name: "Base with rails", + columnCount: 12, + breakpoint: 768, + role: "base", + leftGutterColumnCount: 2, + rightGutterColumnCount: 2, + }, + ], + }, + { handle: "dense-collisions", label: "QA v2 · Dense collisions", ownerHandle: "avery-admin" }, + { handle: "nested-containers", label: "QA v2 · Nested containers", ownerHandle: "avery-admin" }, + { + handle: "layout-boundaries", + label: "QA v2 · Layout boundaries", + ownerHandle: "avery-admin", + layouts: [ + mobileLayout, + { handle: "compact", name: "Compact", columnCount: 6, breakpoint: 480, role: "custom" }, + { handle: "desktop", name: "Desktop", columnCount: 12, breakpoint: 1024, role: "custom" }, + { handle: "base", name: "Base 24", columnCount: 24, breakpoint: 1440, role: "base" }, + ], + }, + { handle: "icons-bookmarks", label: "QA v2 · Icons and bookmarks", ownerHandle: "avery-admin" }, + ...Array.from({ length: 12 }, (_, index) => { + const handle = `widgets-${String(index + 1).padStart(2, "0")}`; + return { handle, label: `QA v2 · ${handle}`, ownerHandle: "avery-admin" }; + }), + { + handle: "custom-widget-assistant", + label: "QA v2 · Custom Widget and Assistant", + ownerHandle: "avery-admin", + }, + { + handle: "permissions-public", + label: "QA v2 · Permissions and public access", + ownerHandle: "rowan-owner", + isPublic: true, + }, + { handle: "download-upload", label: "QA v2 · Download and upload", ownerHandle: "maya-media" }, +]; + +const homeBoards: BoardDefinition[] = personas.map((persona) => ({ + handle: `home-${persona.handle}`, + label: `QA v2 · Home · ${persona.handle}`, + ownerHandle: persona.handle, +})); + +const qaBoards = [...homeBoards, ...sharedBoards]; +const boardName = (handle: string) => { + if (handle === "scroll") return "qa-scroll-lab"; + return `qa-${handle}`; +}; +const validateQaBoardDefinitions = () => { + const names = qaBoards.map((board) => boardName(board.handle)); + if (new Set(names).size !== names.length) throw new Error("QA board names must be unique"); + for (const name of names) { + if (!boardNameSchema.safeParse(name).success) throw new Error(`Invalid QA board name: ${name}`); + } +}; +const userId = (handle: string) => `qa-v2-user-${handle}`; +const groupId = (handle: string) => `qa-v2-group-${handle}`; +const boardId = (handle: string) => `qa-v2-board-${handle}`; +const layoutId = (boardHandle: string, handle: string) => `qa-v2-layout-${boardHandle}-${handle}`; +const sectionId = (boardHandle: string, handle: string) => `qa-v2-section-${boardHandle}-${handle}`; +const itemId = (boardHandle: string, handle: string) => `qa-v2-item-${boardHandle}-${handle}`; +const appId = (index: number) => `qa-v2-app-${String(index).padStart(2, "0")}`; +const iconId = (index: number) => `qa-v2-icon-${String(index).padStart(2, "0")}`; +const qaUserIds = personas.map((persona) => userId(persona.handle)); +const qaBoardIds = qaBoards.map((board) => boardId(board.handle)); +const qaLayoutIds = qaBoards.flatMap((board) => + (board.layouts ?? defaultLayouts).map((layout) => layoutId(board.handle, layout.handle)), +); +const qaGroupIds = [groupId("fixture-access"), ...personas.map((persona) => groupId(persona.handle))]; +const qaAppIds = Array.from({ length: 8 }, (_, index) => appId(index + 1)); +const qaIconIds = Array.from({ length: 8 }, (_, index) => iconId(index + 1)); +const mockIntegrationId = "qa-v2-integration-mock"; +const customDefinitionId = "qa-v2-custom-widget-fixture"; +const disabledCustomDefinitionId = "qa-v2-custom-widget-disabled"; +const missingSecretCustomDefinitionId = "qa-v2-custom-widget-missing-secret"; +const customDefinitionIds = [customDefinitionId, disabledCustomDefinitionId, missingSecretCustomDefinitionId]; +const iconRepositoryId = "qa-v2-icon-repository"; + +const parseCli = (): CliOptions => { + const options: CliOptions = { reset: false }; + for (let index = 2; index < process.argv.length; index++) { + const argument = process.argv[index]; + if (argument === "--") continue; + if (argument === "--reset") { + options.reset = true; + continue; + } + if (!["--candidate-sha", "--manifest", "--output", "--profile"].includes(argument ?? "")) { + throw new Error(`Unknown argument: ${argument ?? ""}`); + } + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}`); + index++; + if (argument === "--candidate-sha") options.candidateSha = value; + if (argument === "--manifest") options.manifestPath = value; + if (argument === "--output") options.outputDirectory = value; + if (argument === "--profile") { + if (!profiles.includes(value as Profile)) throw new Error(`Unsupported profile: ${value}`); + options.profile = value as Profile; + } + } + return options; +}; + +const requiredEnv = (name: string) => { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +}; + +const requiredQaPassword = async () => { + const direct = process.env.QA_PASSWORD?.trim(); + if (direct) return direct; + const passwordFile = process.env.QA_PASSWORD_FILE?.trim(); + if (passwordFile) { + const fromFile = (await readFile(resolve(passwordFile), "utf8")).trim(); + if (fromFile) return fromFile; + } + throw new Error("QA_PASSWORD or QA_PASSWORD_FILE is required"); +}; + +const runInTransactionAsync = async (operation: () => Promise) => { + if ((process.env.DB_DRIVER ?? "better-sqlite3") === "better-sqlite3") { + rootDb.run(sql.raw("BEGIN IMMEDIATE")); + try { + await operation(); + rootDb.run(sql.raw("COMMIT")); + } catch (error) { + rootDb.run(sql.raw("ROLLBACK")); + throw error; + } + return; + } + + try { + await rootDb.transaction(async (transaction) => { + db = transaction as typeof rootDb; + await operation(); + }); + } finally { + db = rootDb; + } +}; + +const resolveManifestPath = (options: CliOptions) => { + const explicit = options.manifestPath ?? process.env.QA_FIXTURE_MANIFEST; + if (explicit) return resolve(explicit); + if (options.outputDirectory) return resolve(options.outputDirectory, "fixture-manifest.json"); + const runRoot = process.env.QA_RUN_ROOT; + const slot = process.env.QA_SLOT; + if (runRoot && slot) return resolve(runRoot, "slots", slot, "fixture-manifest.json"); + throw new Error("--manifest, --output, or QA_FIXTURE_MANIFEST is required"); +}; + +const upsertById = async ( + query: { findFirst: (input: unknown) => Promise }, + table: { id: unknown }, + row: TRow, +) => { + const existing = await query.findFirst({ where: eq(table.id as never, row.id) }); + if (existing) { + await db + .update(table as never) + .set(row as never) + .where(eq(table.id as never, row.id)); + return; + } + await db.insert(table as never).values(row as never); +}; + +const resetQaRowsAsync = async () => { + if (qaBoardIds.length > 0) await db.delete(boards).where(inArray(boards.id, qaBoardIds)); + if (qaUserIds.length > 0) await db.delete(users).where(inArray(users.id, qaUserIds)); + if (qaGroupIds.length > 0) await db.delete(groups).where(inArray(groups.id, qaGroupIds)); + await db.delete(customWidgetDefinitions).where(inArray(customWidgetDefinitions.id, customDefinitionIds)); + await db.delete(integrations).where(eq(integrations.id, mockIntegrationId)); + if (qaAppIds.length > 0) await db.delete(apps).where(inArray(apps.id, qaAppIds)); + if (qaIconIds.length > 0) await db.delete(icons).where(inArray(icons.id, qaIconIds)); + await db.delete(iconRepositories).where(eq(iconRepositories.id, iconRepositoryId)); +}; + +const setOnboardingStepAsync = async (profile: Profile) => { + const row = await db.query.onboarding.findFirst(); + if (!row) throw new Error("Production seed did not create the onboarding row"); + if (profile === "onboarding-fresh") { + await db.update(onboarding).set({ step: "start", previousStep: null }).where(eq(onboarding.id, row.id)); + return; + } + await db.update(onboarding).set({ step: "finish", previousStep: "setup" }).where(eq(onboarding.id, row.id)); +}; + +const resolvePasswordHashAsync = async (password: string) => { + const existing = await db.query.users.findMany({ where: inArray(users.id, qaUserIds), columns: { password: true } }); + for (const row of existing) { + if (row.password && (await comparePasswordsAsync(password, row.password))) return row.password; + } + return hashPasswordAsync(password); +}; + +const seedPersonasAsync = async (passwordHash: string) => { + for (const persona of personas) { + const id = userId(persona.handle); + await upsertById( + db.query.users as never, + users as never, + { + id, + name: persona.handle, + email: `qa-v2-${persona.handle}@example.test`, + emailVerified: null, + image: null, + password: passwordHash, + provider: "credentials", + homeBoardId: null, + mobileHomeBoardId: null, + defaultSearchEngineId: null, + openSearchInNewTab: true, + ddgBangs: true, + colorScheme: "dark", + firstDayOfWeek: 1, + pingIconsEnabled: true, + enableRightClickOnWidgets: true, + completedManageTour: true, + completedBoardTour: true, + } as typeof users.$inferSelect, + ); + } + + await upsertById( + db.query.groups as never, + groups as never, + { + id: groupId("fixture-access"), + name: "QA v2 fixture access", + ownerId: userId("avery-admin"), + homeBoardId: null, + mobileHomeBoardId: null, + position: 10_000, + } as typeof groups.$inferSelect, + ); + + for (const [index, persona] of personas.entries()) { + await upsertById( + db.query.groups as never, + groups as never, + { + id: groupId(persona.handle), + name: `QA v2 role · ${persona.handle}`, + ownerId: userId("avery-admin"), + homeBoardId: null, + mobileHomeBoardId: null, + position: 10_001 + index, + } as typeof groups.$inferSelect, + ); + } + + await db.delete(groupPermissions).where(inArray(groupPermissions.groupId, qaGroupIds)); + await db.delete(groupMembers).where(inArray(groupMembers.userId, qaUserIds)); + await db + .insert(groupPermissions) + .values([ + { groupId: groupId("fixture-access"), permission: "app-use-all" }, + { groupId: groupId("fixture-access"), permission: "integration-use-all" }, + ...personas.flatMap((persona) => + persona.permissions.map((permission) => ({ groupId: groupId(persona.handle), permission })), + ), + ]); + await db + .insert(groupMembers) + .values([ + ...personas.map((persona) => ({ groupId: groupId("fixture-access"), userId: userId(persona.handle) })), + ...personas.map((persona) => ({ groupId: groupId(persona.handle), userId: userId(persona.handle) })), + ]); +}; + +const seedGlobalFixturesAsync = async (fixtureUrl: string) => { + await upsertById( + db.query.integrations as never, + integrations as never, + { + id: mockIntegrationId, + name: "QA v2 built-in mock integration", + url: fixtureUrl, + kind: "mock", + appId: null, + } as typeof integrations.$inferSelect, + ); + + const appRows = qaAppIds.map((id, index) => ({ + id, + name: `QA Fixture ${String(index + 1).padStart(2, "0")}`, + description: `Deterministic release v2 QA bookmark ${index + 1}`, + iconUrl: `${fixtureUrl}/media/image.png?app=${index + 1}`, + href: + [ + `${fixtureUrl}/api/qa/download/sample.txt`, + null, + "/relative/qa-v2", + "https://example.com/qa-v2-external", + `${fixtureUrl}/error?status=404`, + `${fixtureUrl}/iframe?app=6`, + `${fixtureUrl}/empty`, + `${fixtureUrl}/slow?ms=200`, + ][index] ?? null, + pingUrl: index === 1 ? null : index === 4 ? `${fixtureUrl}/error?status=503` : `${fixtureUrl}/json`, + })); + for (const row of appRows) await upsertById(db.query.apps as never, apps as never, row); + + await upsertById(db.query.iconRepositories as never, iconRepositories as never, { + id: iconRepositoryId, + slug: "qa-v2/release-fixtures", + }); + for (const [index, id] of qaIconIds.entries()) { + await upsertById(db.query.icons as never, icons as never, { + id, + name: `qa-v2-icon-${String(index + 1).padStart(2, "0")}`, + url: `${fixtureUrl}/media/image.png?icon=${index + 1}`, + checksum: `qa-v2-checksum-${String(index + 1).padStart(2, "0")}`, + iconRepositoryId, + }); + } + + const definition = customWidgetDefinitionSchema.parse({ + $schema: "homarr-custom-widget-v2", + name: "Release v2 QA fixture", + description: "Credential-free deterministic Custom JSX fixture for release v2 browser QA.", + sources: { + default: { name: "QA fixture", baseUrl: fixtureUrl, networkScope: "loopback", auth: "none" }, + }, + requests: { fixture: { path: "/api/qa/custom-widget", cacheSeconds: 0 } }, + options: {}, + template: `{data.fixture?.title ?? "Release v2 QA"}{data.fixture?.status ?? "loading"}{status.fixture?.loading ? : status.fixture?.error ? {status.fixture.error} : {(data.fixture?.items ?? []).map(item => {item.label}{item.value})}}`, + }); + const missingSecretDefinition = customWidgetDefinitionSchema.parse({ + ...definition, + name: "Release v2 QA missing secret", + description: "Valid bearer-auth definition intentionally stored without a secret.", + sources: { + default: { name: "QA protected fixture", baseUrl: fixtureUrl, networkScope: "loopback", auth: "bearer" }, + }, + }); + const definitions = [ + { id: customDefinitionId, definition, enabled: true }, + { id: disabledCustomDefinitionId, definition: { ...definition, name: "Release v2 QA disabled" }, enabled: false }, + { id: missingSecretCustomDefinitionId, definition: missingSecretDefinition, enabled: true }, + ]; + for (const row of definitions) { + await upsertById( + db.query.customWidgetDefinitions as never, + customWidgetDefinitions as never, + { + id: row.id, + name: row.definition.name, + description: row.definition.description ?? null, + iconUrl: null, + sources: stringify(row.definition.sources), + requests: stringify(row.definition.requests), + options: stringify(row.definition.options), + template: row.definition.template, + enabled: row.enabled, + createdAt: new Date(0), + updatedAt: new Date(0), + creatorId: userId("avery-admin"), + } as typeof customWidgetDefinitions.$inferSelect, + ); + } +}; + +const sourceOptionsByKindAsync = async () => { + let sourceBoard = await db.query.boards.findFirst({ where: eq(boards.name, "default"), with: { items: true } }); + sourceBoard ??= await db.query.boards.findFirst({ where: eq(boards.name, "dashboard"), with: { items: true } }); + if (!sourceBoard) throw new Error("Production seed did not create a widget gallery board"); + const result = new Map(); + for (const kind of widgetKinds) { + const source = sourceBoard.items.find((item) => item.kind === kind); + result.set(kind, source?.options ?? emptySuperJSON); + } + result.set("app", stringify({ appId: qaAppIds[0], openInNewTab: true, showTitle: true, pingEnabled: false })); + result.set("bookmarks", stringify({ title: "QA bookmarks", layout: "grid", items: qaAppIds, openNewTab: true })); + result.set( + "customApi", + stringify({ definitionId: customDefinitionId, configuration: {}, configurationVersion: 1, refreshInterval: 1 }), + ); + return result; +}; + +const boardLayouts = (board: BoardDefinition) => board.layouts ?? defaultLayouts; + +const emptySectionOffset = (sectionHandle: string) => { + if (sectionHandle === "left") return -1; + if (sectionHandle === "right") return 1; + return 0; +}; + +const mainLaneColumnCount = (layout: LayoutDefinition) => getBoardLaneColumnCount(layout, "main"); + +const sectionColumnCount = (layout: LayoutDefinition, sectionHandle: string) => { + if (["root", "left", "right"].includes(sectionHandle)) { + return getBoardLaneColumnCount(layout, getRootSectionLane(emptySectionOffset(sectionHandle))); + } + const mainColumns = mainLaneColumnCount(layout); + if (sectionHandle === "overflow") return Math.min(mainColumns, 6); + if (sectionHandle === "level-1") return mainColumns; + if (sectionHandle === "level-2") return Math.max(1, mainColumns - 1); + if (sectionHandle === "level-3") return Math.max(1, mainColumns - 2); + if (sectionHandle.startsWith("sibling-")) return Math.min(mainColumns, 2); + throw new Error(`Unknown QA section handle: ${sectionHandle}`); +}; + +const resolveItemSectionHandle = (layout: LayoutDefinition, requestedHandle: string) => { + if (!["left", "right"].includes(requestedHandle)) return requestedHandle; + if (sectionColumnCount(layout, requestedHandle) > 0) return requestedHandle; + return "root"; +}; + +const packPositions = (itemDefinitions: ItemDefinition[], board: BoardDefinition): Position[] => { + return boardLayouts(board).flatMap((layout) => { + const cursors = new Map(); + return itemDefinitions.map((item) => { + const sectionHandle = resolveItemSectionHandle(layout, item.sectionHandle ?? "root"); + const columnCount = sectionColumnCount(layout, sectionHandle); + const width = Math.min(item.width, columnCount); + const cursor = cursors.get(sectionHandle) ?? { xOffset: 0, yOffset: 0, rowHeight: 0 }; + if (cursor.xOffset + width > columnCount) { + cursor.xOffset = 0; + cursor.yOffset += cursor.rowHeight; + cursor.rowHeight = 0; + } + const position = { + boardHandle: item.boardHandle, + itemHandle: item.handle, + layoutHandle: layout.handle, + sectionHandle, + xOffset: cursor.xOffset, + yOffset: cursor.yOffset, + width, + height: item.height, + }; + cursor.xOffset += width; + cursor.rowHeight = Math.max(cursor.rowHeight, item.height); + cursors.set(sectionHandle, cursor); + return position; + }); + }); +}; + +const itemForKind = ( + boardHandle: string, + handle: string, + kind: WidgetKind, + optionsByKind: Map, +): ItemDefinition => { + const defaultConfig = getDefaultWidgetConfig(kind); + let options = optionsByKind.get(kind) ?? emptySuperJSON; + if (options === emptySuperJSON && defaultConfig.options) options = stringify(defaultConfig.options); + return { + boardHandle, + handle, + kind, + options, + width: defaultConfig.width, + height: defaultConfig.height, + }; +}; + +const buildItems = (optionsByKind: Map) => { + const result = new Map(); + for (const board of homeBoards) { + result.set( + board.handle, + (["clock", "weather", "bookmarks"] as WidgetKind[]).map((kind, index) => + itemForKind(board.handle, `${kind}-${index + 1}`, kind, optionsByKind), + ), + ); + } + result.set( + "grid-24", + Array.from({ length: 24 }, (_, index) => ({ + ...itemForKind("grid-24", `cell-${String(index + 1).padStart(2, "0")}`, "clock", optionsByKind), + width: index + 1, + height: (index % 6) + 1, + })), + ); + result.set("scroll", [ + ...Array.from({ length: 30 }, (_, index) => ({ + ...itemForKind( + "scroll", + `row-${String(index + 1).padStart(2, "0")}`, + index % 2 === 0 ? "notebook" : "weather", + optionsByKind, + ), + sectionHandle: ["left", "root", "right"][index % 3], + })), + ...Array.from({ length: 12 }, (_, index) => ({ + ...itemForKind("scroll", `overflow-${index + 1}`, index % 2 === 0 ? "clock" : "app", optionsByKind), + sectionHandle: "overflow", + })), + ]); + result.set( + "dense-collisions", + Array.from({ length: 12 }, (_, index) => + itemForKind("dense-collisions", `collision-${index + 1}`, "clock", optionsByKind), + ), + ); + result.set("nested-containers", [ + ...(["clock", "weather", "bookmarks"] as WidgetKind[]).map((kind, index) => ({ + ...itemForKind("nested-containers", `nested-${index + 1}`, kind, optionsByKind), + sectionHandle: "level-3", + })), + ...(["notebook", "app", "countdown"] as WidgetKind[]).map((kind, index) => ({ + ...itemForKind("nested-containers", `sibling-${index + 1}`, kind, optionsByKind), + sectionHandle: `sibling-${index + 1}`, + })), + itemForKind("nested-containers", "root-zone", "clock", optionsByKind), + ]); + result.set( + "layout-boundaries", + widgetKinds + .slice(0, 16) + .map((kind, index) => itemForKind("layout-boundaries", `boundary-${index + 1}`, kind, optionsByKind)), + ); + result.set("icons-bookmarks", [ + { + ...itemForKind("icons-bookmarks", "bookmarks-grid", "bookmarks", optionsByKind), + options: stringify({ title: "QA grid", layout: "grid", items: qaAppIds, openNewTab: true }), + }, + { + ...itemForKind("icons-bookmarks", "bookmarks-list", "bookmarks", optionsByKind), + options: stringify({ title: "QA list", layout: "column", items: qaAppIds, openNewTab: false }), + }, + { + ...itemForKind("icons-bookmarks", "bookmarks-empty-title", "bookmarks", optionsByKind), + options: stringify({ title: "", layout: "icons", items: qaAppIds.slice(0, 4), openNewTab: true }), + }, + { + ...itemForKind("icons-bookmarks", "bookmarks-custom-title", "bookmarks", optionsByKind), + options: stringify({ + title: "External and relative", + layout: "row", + items: qaAppIds.slice(2, 6), + openNewTab: false, + }), + }, + ...qaAppIds.slice(0, 6).map((id, index) => ({ + ...itemForKind("icons-bookmarks", `app-${index + 1}`, "app", optionsByKind), + options: stringify({ + appId: id, + openInNewTab: index % 2 === 0, + showTitle: index >= 3, + pingEnabled: index !== 1, + }), + })), + ]); + widgetGalleryGroups.forEach((group, groupIndex) => { + const boardHandle = `widgets-${String(groupIndex + 1).padStart(2, "0")}`; + result.set( + boardHandle, + group.map((kind, kindIndex) => + itemForKind(boardHandle, `${String(kindIndex + 1).padStart(2, "0")}-${kind}`, kind, optionsByKind), + ), + ); + }); + result.set("custom-widget-assistant", [ + { + ...itemForKind("custom-widget-assistant", "custom-compact", "customApi", optionsByKind), + width: 3, + height: 2, + }, + { + ...itemForKind("custom-widget-assistant", "custom-standard-disabled", "customApi", optionsByKind), + options: stringify({ + definitionId: disabledCustomDefinitionId, + configuration: {}, + configurationVersion: 1, + refreshInterval: 1, + }), + width: 6, + height: 4, + }, + { + ...itemForKind("custom-widget-assistant", "custom-wide-missing-secret", "customApi", optionsByKind), + options: stringify({ + definitionId: missingSecretCustomDefinitionId, + configuration: {}, + configurationVersion: 1, + refreshInterval: 1, + }), + width: 12, + height: 4, + }, + { + ...itemForKind("custom-widget-assistant", "custom-invalid-option", "customApi", optionsByKind), + options: stringify({ + definitionId: customDefinitionId, + configuration: { unsupportedOption: "qa-invalid" }, + configurationVersion: 1, + refreshInterval: 1, + }), + width: 6, + height: 3, + }, + { ...itemForKind("custom-widget-assistant", "assistant-compact", "assistant", optionsByKind), width: 3, height: 2 }, + { + ...itemForKind("custom-widget-assistant", "assistant-standard", "assistant", optionsByKind), + width: 6, + height: 4, + }, + { ...itemForKind("custom-widget-assistant", "assistant-wide", "assistant", optionsByKind), width: 12, height: 4 }, + ]); + result.set("permissions-public", [ + itemForKind("permissions-public", "clock", "clock", optionsByKind), + itemForKind("permissions-public", "bookmarks", "bookmarks", optionsByKind), + ]); + result.set("download-upload", [ + { ...itemForKind("download-upload", "downloads", "downloads", optionsByKind), width: 6, height: 3 }, + { + ...itemForKind("download-upload", "download-link", "app", optionsByKind), + options: stringify({ appId: qaAppIds[0], openInNewTab: true, showTitle: true, pingEnabled: false }), + }, + ]); + return result; +}; + +const assertSeededGeometryAsync = async () => { + const geometryLayouts = await db + .select({ + id: layouts.id, + boardId: layouts.boardId, + columnCount: layouts.columnCount, + leftGutterColumnCount: layouts.leftGutterColumnCount, + rightGutterColumnCount: layouts.rightGutterColumnCount, + role: layouts.role, + }) + .from(layouts) + .where(inArray(layouts.id, qaLayoutIds)); + const geometrySections = await db + .select({ + id: sections.id, + boardId: sections.boardId, + kind: sections.kind, + xOffset: sections.xOffset, + }) + .from(sections) + .where(inArray(sections.boardId, qaBoardIds)); + const geometrySectionLayouts = await db + .select({ + sectionId: sectionLayouts.sectionId, + layoutId: sectionLayouts.layoutId, + parentSectionId: sectionLayouts.parentSectionId, + xOffset: sectionLayouts.xOffset, + yOffset: sectionLayouts.yOffset, + width: sectionLayouts.width, + height: sectionLayouts.height, + }) + .from(sectionLayouts) + .where(inArray(sectionLayouts.layoutId, qaLayoutIds)); + const geometryItems = await db + .select({ id: items.id, boardId: items.boardId }) + .from(items) + .where(inArray(items.boardId, qaBoardIds)); + const itemBoardById = new Map(geometryItems.map((item) => [item.id, item.boardId])); + const geometryItemLayouts = await db + .select({ + itemId: itemLayouts.itemId, + sectionId: itemLayouts.sectionId, + layoutId: itemLayouts.layoutId, + xOffset: itemLayouts.xOffset, + yOffset: itemLayouts.yOffset, + width: itemLayouts.width, + height: itemLayouts.height, + }) + .from(itemLayouts) + .where(inArray(itemLayouts.layoutId, qaLayoutIds)); + const geometryErrors = findFixtureGeometryErrors({ + layouts: geometryLayouts, + sections: geometrySections, + sectionLayouts: geometrySectionLayouts, + itemLayouts: geometryItemLayouts.map((itemLayout) => ({ + ...itemLayout, + boardId: itemBoardById.get(itemLayout.itemId) ?? "", + })), + }); + if (geometryErrors.length > 0) { + throw new Error(`Generated QA fixture geometry is invalid: ${geometryErrors.join("; ")}`); + } +}; + +const seedBoardsAsync = async (optionsByKind: Map) => { + for (const board of qaBoards) { + const name = boardName(board.handle); + await upsertById( + db.query.boards as never, + boards as never, + { + id: boardId(board.handle), + name, + isPublic: board.isPublic ?? false, + creatorId: userId(board.ownerHandle), + pageTitle: board.label, + metaTitle: board.label, + logoImageUrl: null, + faviconImageUrl: null, + backgroundImageUrl: null, + backgroundImageAttachment: "fixed", + backgroundImageRepeat: "no-repeat", + backgroundImageSize: "cover", + primaryColor: "#748ffc", + secondaryColor: "#22b8cf", + opacity: 95, + customCss: null, + iconColor: null, + itemRadius: "md", + disableStatus: false, + } as typeof boards.$inferSelect, + ); + } + + await db.delete(items).where(inArray(items.boardId, qaBoardIds)); + await db.delete(sections).where(inArray(sections.boardId, qaBoardIds)); + await db.delete(layouts).where(inArray(layouts.boardId, qaBoardIds)); + + const allItems = buildItems(optionsByKind); + const positionRows: Position[] = []; + for (const board of qaBoards) { + await db.insert(layouts).values( + boardLayouts(board).map((layout) => ({ + id: layoutId(board.handle, layout.handle), + name: layout.name, + boardId: boardId(board.handle), + columnCount: layout.columnCount, + leftGutterColumnCount: layout.leftGutterColumnCount ?? 0, + rightGutterColumnCount: layout.rightGutterColumnCount ?? 0, + breakpoint: layout.breakpoint, + role: layout.role, + })), + ); + await db.insert(sections).values({ + id: sectionId(board.handle, "root"), + boardId: boardId(board.handle), + kind: "empty", + xOffset: 0, + yOffset: 0, + name: null, + options: emptySuperJSON, + }); + if (board.handle === "scroll") { + await db.insert(sections).values([ + { + id: sectionId(board.handle, "left"), + boardId: boardId(board.handle), + kind: "empty", + xOffset: -1, + yOffset: 0, + name: "Left rail", + options: emptySuperJSON, + }, + { + id: sectionId(board.handle, "right"), + boardId: boardId(board.handle), + kind: "empty", + xOffset: 1, + yOffset: 0, + name: "Right rail", + options: emptySuperJSON, + }, + { + id: sectionId(board.handle, "overflow"), + boardId: boardId(board.handle), + kind: "container", + xOffset: null, + yOffset: null, + name: "Scrollable container", + options: stringify({ showLabel: true, collapsible: true, scrollable: true, showOpenAll: true }), + }, + ]); + await db.insert(sectionLayouts).values( + boardLayouts(board).map((layout) => ({ + sectionId: sectionId(board.handle, "overflow"), + layoutId: layoutId(board.handle, layout.handle), + parentSectionId: sectionId(board.handle, "root"), + xOffset: 0, + yOffset: 0, + width: sectionColumnCount(layout, "overflow"), + height: 4, + })), + ); + } + if (board.handle === "nested-containers") { + const containerHandles = ["level-1", "level-2", "level-3", "sibling-1", "sibling-2", "sibling-3"]; + await db.insert(sections).values( + containerHandles.map((handle, index) => ({ + id: sectionId(board.handle, handle), + boardId: boardId(board.handle), + kind: "container" as const, + xOffset: null, + yOffset: null, + name: index < 3 ? `Nested level ${index + 1}` : `Sibling zone ${index - 2}`, + options: stringify({ + showLabel: index !== 4, + collapsible: index !== 5, + scrollable: index === 2 || index === 4, + showOpenAll: index === 3, + }), + })), + ); + await db.insert(sectionLayouts).values( + boardLayouts(board).flatMap((layout) => + containerHandles.map((handle, index) => { + const width = sectionColumnCount(layout, handle); + const parentHandle = index === 0 ? "root" : index < 3 ? `level-${index}` : "root"; + const parentColumns = sectionColumnCount(layout, parentHandle); + return { + sectionId: sectionId(board.handle, handle), + layoutId: layoutId(board.handle, layout.handle), + parentSectionId: sectionId(board.handle, parentHandle), + xOffset: index < 3 ? 0 : Math.max(0, Math.min((index - 3) * 2, parentColumns - width)), + yOffset: index < 3 ? 0 : 7, + width, + height: index < 3 ? 6 - index : 3, + }; + }), + ), + ); + } + const boardItems = allItems.get(board.handle) ?? []; + if (boardItems.length > 0) { + await db.insert(items).values( + boardItems.map((item) => ({ + id: itemId(board.handle, item.handle), + boardId: boardId(board.handle), + kind: item.kind, + options: item.options, + advancedOptions: item.advancedOptions ?? emptySuperJSON, + })), + ); + } + if (board.handle === "dense-collisions") { + positionRows.push( + ...boardLayouts(board).flatMap((layout) => + boardItems.map((item, index) => { + const columnCount = mainLaneColumnCount(layout); + const width = Math.min(columnCount, 2 + (index % 3)); + return { + boardHandle: board.handle, + itemHandle: item.handle, + layoutHandle: layout.handle, + sectionHandle: "root", + xOffset: Math.min(index % 3, columnCount - width), + yOffset: index % 2, + width, + height: 2 + (index % 2), + }; + }), + ), + ); + } else { + positionRows.push(...packPositions(boardItems, board)); + } + } + + if (positionRows.length > 0) { + await db.insert(itemLayouts).values( + positionRows.map((position) => { + return { + itemId: itemId(position.boardHandle, position.itemHandle), + sectionId: sectionId(position.boardHandle, position.sectionHandle), + layoutId: layoutId(position.boardHandle, position.layoutHandle), + xOffset: position.xOffset, + yOffset: position.yOffset, + width: position.width, + height: position.height, + }; + }), + ); + } + await assertSeededGeometryAsync(); + + const mockKinds = new Set(getWidgetKindsForIntegration("mock")); + const mockItemIds = [...allItems.entries()].flatMap(([boardHandle, definitions]) => + definitions.filter((item) => mockKinds.has(item.kind)).map((item) => itemId(boardHandle, item.handle)), + ); + if (mockItemIds.length > 0) { + await db + .insert(integrationItems) + .values(mockItemIds.map((currentItemId) => ({ itemId: currentItemId, integrationId: mockIntegrationId }))); + } + + await db.delete(boardUserPermissions).where(inArray(boardUserPermissions.boardId, qaBoardIds)); + await db.delete(boardGroupPermissions).where(inArray(boardGroupPermissions.boardId, qaBoardIds)); + const permissionRows: Array<{ boardId: string; userId: string; permission: BoardPermission }> = []; + for (const board of sharedBoards) { + permissionRows.push( + { boardId: boardId(board.handle), userId: userId("rowan-owner"), permission: "full" }, + { boardId: boardId(board.handle), userId: userId("eden-editor"), permission: "modify" }, + { boardId: boardId(board.handle), userId: userId("vivian-viewer"), permission: "view" }, + ); + for (const persona of personas) { + if (["avery-admin", "rowan-owner", "eden-editor", "vivian-viewer", "nolan-outsider"].includes(persona.handle)) { + continue; + } + permissionRows.push({ boardId: boardId(board.handle), userId: userId(persona.handle), permission: "view" }); + } + } + await db.insert(boardUserPermissions).values(permissionRows); + + for (const persona of personas) { + await db + .update(users) + .set({ homeBoardId: boardId(`home-${persona.handle}`), mobileHomeBoardId: boardId(`home-${persona.handle}`) }) + .where(eq(users.id, userId(persona.handle))); + } +}; + +const createManifest = ( + profile: Profile, + candidateSha: string, + fixtureUrl: string, + seeded: boolean, + profileFlags: ReleaseV2QaProfileFlags, +) => { + const allItems = seeded ? buildItems(new Map(widgetKinds.map((kind) => [kind, emptySuperJSON]))) : new Map(); + const boardEntries = seeded + ? qaBoards.map((board) => ({ + id: boardId(board.handle), + handle: board.handle, + name: boardName(board.handle), + label: board.label, + owner: board.ownerHandle, + isPublic: board.isPublic ?? false, + layouts: boardLayouts(board).map((layout) => layoutId(board.handle, layout.handle)), + itemCount: allItems.get(board.handle)?.length ?? 0, + flags: { + home: board.handle.startsWith("home-"), + shared: !board.handle.startsWith("home-"), + collisionFixture: board.handle === "dense-collisions", + nestedContainers: board.handle === "nested-containers", + scrollableContainer: board.handle === "scroll", + leftRightRails: board.handle === "scroll", + }, + })) + : []; + const layoutEntries = seeded + ? qaBoards.flatMap((board) => + boardLayouts(board).map((layout) => ({ + id: layoutId(board.handle, layout.handle), + boardId: boardId(board.handle), + handle: layout.handle, + role: layout.role, + breakpoint: layout.breakpoint, + columnCount: layout.columnCount, + leftGutterColumnCount: layout.leftGutterColumnCount ?? 0, + rightGutterColumnCount: layout.rightGutterColumnCount ?? 0, + })), + ) + : []; + return { + schemaVersion: 1, + candidateSha, + profile, + personas: seeded + ? personas.map((persona) => ({ + id: userId(persona.handle), + handle: persona.handle, + name: persona.name, + loginUsername: persona.handle, + email: `qa-v2-${persona.handle}@example.test`, + homeBoardId: boardId(`home-${persona.handle}`), + permissions: persona.permissions, + })) + : [], + boards: boardEntries, + layouts: layoutEntries, + expectedBoardPermissions: { + "rowan-owner": "full", + "eden-editor": "modify", + "vivian-viewer": "view", + "nolan-outsider": "none", + }, + counts: { + personas: seeded ? personas.length : 0, + boards: boardEntries.length, + homeBoards: seeded ? homeBoards.length : 0, + sharedBoards: seeded ? sharedBoards.length : 0, + layouts: layoutEntries.length, + items: seeded ? [...allItems.values()].reduce((sum, rows) => sum + rows.length, 0) : 0, + widgetKinds: seeded ? widgetKinds.length : 0, + apps: seeded ? qaAppIds.length : 0, + icons: seeded ? qaIconIds.length : 0, + customWidgets: seeded ? customDefinitionIds.length : 0, + }, + customWidgetVariants: seeded + ? { + enabled: customDefinitionId, + disabled: disabledCustomDefinitionId, + missingSecret: missingSecretCustomDefinitionId, + invalidOptionItem: itemId("custom-widget-assistant", "custom-invalid-option"), + sizes: ["compact", "standard", "wide"], + } + : null, + limitations: ["No plaintext Custom Widget secret is seeded; the missing-secret state is intentional."], + flags: { + credentialsCreated: seeded, + readOnly: profile === "main-readonly", + onboardingFresh: profile === "onboarding-fresh", + degraded: profile === "degraded", + mockIntegration: seeded, + allWidgetKindsCovered: seeded, + demoMode: profileFlags.demoMode, + demoReadOnly: profileFlags.demoReadOnly, + unsafeMockIntegration: profileFlags.unsafeMockIntegration, + fixtureOrigin: new URL(fixtureUrl).origin, + }, + }; +}; + +const main = async () => { + const options = parseCli(); + validateQaBoardDefinitions(); + const galleryKinds = widgetGalleryGroups.flat(); + if ( + galleryKinds.length !== widgetKinds.length || + new Set(galleryKinds).size !== widgetKinds.length || + widgetKinds.some((kind) => !galleryKinds.includes(kind)) + ) { + throw new Error("Widget gallery groups must contain every canonical widget kind exactly once"); + } + const profile = options.profile ?? (process.env.QA_PROFILE as Profile | undefined); + if (!profile || !profiles.includes(profile)) throw new Error(`QA_PROFILE must be one of: ${profiles.join(", ")}`); + const profileFlags = assertReleaseV2QaProfileEnvironment(profile, process.env); + const checkoutSha = resolveCheckoutCandidateSha(repoRoot); + const suppliedCandidateSha = options.candidateSha ?? process.env.QA_CANDIDATE_SHA?.trim(); + const candidateSha = suppliedCandidateSha ? validateCandidateSha(suppliedCandidateSha, checkoutSha) : checkoutSha; + const fixtureUrl = validateFixtureUrl(requiredEnv("QA_FIXTURE_URL")); + const manifestPath = resolveManifestPath(options); + + const populated = profile !== "onboarding-fresh"; + await runInTransactionAsync(async () => { + if (options.reset) await resetQaRowsAsync(); + await setOnboardingStepAsync(profile); + if (populated) { + const password = await requiredQaPassword(); + const passwordHash = await resolvePasswordHashAsync(password); + await seedPersonasAsync(passwordHash); + await seedGlobalFixturesAsync(fixtureUrl); + const optionsByKind = await sourceOptionsByKindAsync(); + await seedBoardsAsync(optionsByKind); + return; + } + await resetQaRowsAsync(); + }); + + const manifest = createManifest(profile, candidateSha, fixtureUrl, populated, profileFlags); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + console.log(`Release v2 QA seed complete for profile '${profile}' (${manifest.counts.boards} QA boards)`); + console.log(`Fixture manifest written to ${manifestPath}`); +}; + +const closeDatabaseAsync = async () => { + const client = rootDb.$client as unknown as { + close?: () => void; + end?: () => Promise | void; + }; + if (typeof client.close === "function") { + client.close(); + return; + } + if (typeof client.end === "function") await client.end(); +}; + +try { + await main(); +} catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown seed failure"; + console.error(`Release v2 QA seed failed: ${message}`); + process.exitCode = 1; +} finally { + await closeDatabaseAsync(); +} + +process.exit(process.exitCode ?? 0);