import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { beginGridTransaction, cancelGridTransaction, commitGridTransaction, previewGridMove, previewGridResize, } from "../../apps/nextjs/src/components/board/sections/grid/dnd/engine.ts"; import type { GridMoveInput, GridResizeDirection, GridResizeInput, GridTransaction, TransactionalGridState, } from "../../apps/nextjs/src/components/board/sections/grid/dnd/types.ts"; const usage = "pnpm exec tsx --tsconfig apps/nextjs/tsconfig.json scripts/regressions/grid-fuzz.mts [--seed 6545] [--steps 1000] [--output /tmp/homarr-grid-fuzz]"; const values = new Map(); for (let index = 2; index < process.argv.length; index += 2) { const flag = process.argv[index]; if (flag === "--help") { console.log(usage); process.exit(0); } const value = process.argv[index + 1]; if (!flag || !["--seed", "--steps", "--output"].includes(flag) || !value || values.has(flag)) { throw new Error(`Invalid or duplicate argument: ${String(flag)}\n${usage}`); } values.set(flag, value); } const integerOption = (flag: string, fallback: number, minimum: number, maximum: number) => { const value = values.get(flag) ?? String(fallback); const number = Number(value); if (!/^\d+$/.test(value) || !Number.isSafeInteger(number) || number < minimum || number > maximum) { throw new Error(`${flag} must be an integer between ${minimum} and ${maximum}`); } return number; }; const seed = integerOption("--seed", 6545, 0, 0xffff_ffff); const steps = integerOption("--steps", 1000, 1, 100_000); const outputDirectory = path.resolve(values.get("--output") ?? "/tmp/homarr-grid-fuzz"); const root = path.resolve(import.meta.dirname, "../.."); // Mulberry32 makes the complete operation stream repeatable, including seed zero. let randomState = seed; const randomInt = (minimum: number, maximum: number) => { randomState = (randomState + 0x6d2b79f5) >>> 0; let value = randomState; value = Math.imul(value ^ (value >>> 15), value | 1); value ^= value + Math.imul(value ^ (value >>> 7), value | 61); const fraction = ((value ^ (value >>> 14)) >>> 0) / 0x1_0000_0000; return minimum + Math.floor(fraction * (maximum - minimum + 1)); }; const pick = (choices: readonly T[]): T => { const value = choices[randomInt(0, choices.length - 1)]; assert.notEqual(value, undefined, "Cannot pick from an empty collection"); return value as T; }; interface Placement { id: string; x: number; y: number; w: number; h: number; type: "section" | "item"; marker: string; } type State = TransactionalGridState; const placement = (id: string, y: number, type: Placement["type"] = "item"): Placement => ({ id, x: 0, y, w: randomInt(1, 3), h: randomInt(1, 2), type, marker: `metadata:${id}`, }); const initialState: State = { grids: [ { id: "root", columnCount: randomInt(6, 12), maxRowCount: 24, placements: [ placement("container", 0, "section"), ...Array.from({ length: 5 }, (_, i) => placement(`root-${i}`, 3 + i * 3)), ], }, { id: "other", columnCount: randomInt(4, 10), maxRowCount: 24, placements: Array.from({ length: 4 }, (_, i) => placement(`other-${i}`, i * 3)), }, { id: "child", columnCount: randomInt(4, 8), maxRowCount: 24, parentGridId: "root", ownerPlacementId: "container", placements: [placement("nested-container", 0, "section"), placement("child-item", 3)], }, { id: "nested", columnCount: randomInt(3, 6), maxRowCount: 24, parentGridId: "child", ownerPlacementId: "nested-container", placements: [placement("nested-item", 0)], }, ], }; const allPlacements = (state: State) => state.grids.flatMap((grid) => grid.placements); const expectedMetadata = allPlacements(initialState) .map(({ id, type, marker }) => ({ id, type, marker })) .toSorted((a, b) => a.id.localeCompare(b.id)); const freezeState = (state: State) => { for (const grid of state.grids) { for (const item of grid.placements) Object.freeze(item); Object.freeze(grid.placements); Object.freeze(grid); } Object.freeze(state.grids); return Object.freeze(state); }; const assertState = (state: State) => { assert.deepEqual( state.grids.map(({ id }) => id).toSorted(), initialState.grids.map(({ id }) => id).toSorted(), "Grid IDs changed", ); assert.deepEqual( allPlacements(state) .map(({ id, type, marker }) => ({ id, type, marker })) .toSorted((a, b) => a.id.localeCompare(b.id)), expectedMetadata, "Placement IDs or metadata changed", ); for (const grid of state.grids) { for (const [index, item] of grid.placements.entries()) { assert.ok([item.x, item.y, item.w, item.h].every(Number.isInteger), `Non-integer geometry: ${item.id}`); assert.ok(item.x >= 0 && item.y >= 0 && item.w >= 1 && item.h >= 1, `Invalid geometry: ${item.id}`); assert.ok( item.x + item.w <= grid.columnCount && item.y + item.h <= (grid.maxRowCount ?? Infinity), `Out of bounds: ${item.id}`, ); for (const other of grid.placements.slice(index + 1)) { const overlaps = item.x < other.x + other.w && item.x + item.w > other.x && item.y < other.y + other.h && item.y + item.h > other.y; assert.ok(!overlaps, `Overlap: ${item.id} and ${other.id} in ${grid.id}`); } } if (grid.ownerPlacementId) { const parent = state.grids.find(({ id }) => id === grid.parentGridId); assert.ok( parent?.placements.some(({ id }) => id === grid.ownerPlacementId), `Missing owner: ${grid.id}`, ); } const visited = new Set([grid.id]); let parentId = grid.parentGridId; while (parentId) { assert.ok(!visited.has(parentId), `Ownership cycle: ${grid.id}`); visited.add(parentId); const parent = state.grids.find(({ id }) => id === parentId); assert.ok(parent, `Missing parent: ${parentId}`); parentId = parent.parentGridId; } } }; type Operation = { kind: "move"; input: GridMoveInput } | { kind: "resize"; input: GridResizeInput }; const applyOperation = (transaction: GridTransaction, operation: Operation) => { if (operation.kind === "move") return previewGridMove(transaction, operation.input); return previewGridResize(transaction, operation.input); }; interface TranscriptEntry { step: number; activeId: string; previews: Array<{ operation: Operation; accepted?: boolean; rejection?: string }>; completion: "commit" | "cancel"; } const directions: GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]; const transcript: TranscriptEntry[] = []; const counts = { accepted: 0, rejected: 0, commits: 0, cancels: 0, rejections: {} as Record }; let state = freezeState(initialState); try { assertState(state); for (let step = 0; step < steps; step += 1) { const activeId = pick(allPlacements(state)).id; const entry: TranscriptEntry = { step, activeId, previews: [], completion: "commit" }; if (randomInt(0, 3) === 0) entry.completion = "cancel"; transcript.push(entry); const started = beginGridTransaction(state, { activeId }); const snapshot = structuredClone(started.snapshot); let transaction = started; const move = randomInt(0, 1) === 0; const previewCount = randomInt(1, 4); for (let preview = 0; preview < previewCount; preview += 1) { let operation: Operation; if (move) { let targetGridId = pick(state.grids).id; if (randomInt(0, 19) === 0) targetGridId = "missing-grid"; operation = { kind: "move", input: { targetGridId, x: randomInt(-4, 16) / 2, y: randomInt(-4, 56) / 2 } }; } else { operation = { kind: "resize", input: { direction: pick(directions), deltaColumns: randomInt(-12, 12), deltaRows: randomInt(-12, 12), minWidth: 1, minHeight: 1, }, }; } const event: TranscriptEntry["previews"][number] = { operation }; entry.previews.push(event); const previous = structuredClone(transaction); const result = applyOperation(transaction, operation); assert.deepEqual(transaction, previous, "Preview mutated its input transaction"); event.accepted = result.accepted; if (result.accepted) { counts.accepted += 1; // Repeated pointer events must not accumulate displacement. const repeated = applyOperation(result.transaction, operation); assert.deepEqual(repeated, result, "Identical preview accumulated drift"); transaction = result.transaction; } else { counts.rejected += 1; event.rejection = result.rejection.code; counts.rejections[result.rejection.code] = (counts.rejections[result.rejection.code] ?? 0) + 1; assert.equal(result.transaction, transaction, "Rejection discarded the last accepted preview"); } assert.deepEqual(transaction.snapshot, snapshot, "Transaction snapshot changed"); assertState(transaction.preview); } if (entry.completion === "cancel") { state = cancelGridTransaction(transaction); assert.deepEqual(state, snapshot, "Cancel failed to restore the starting state"); counts.cancels += 1; } else { state = commitGridTransaction(transaction); assert.deepEqual(state, transaction.preview, "Commit differs from accepted preview"); counts.commits += 1; } assert.notEqual(state, transaction.preview, "Completion retained transaction state reference"); assertState(state); freezeState(state); } console.log(JSON.stringify({ status: "passed", scope: "v2-grid-invariants", seed, steps, ...counts }, null, 2)); } catch (error) { const sources = [ "scripts/regressions/grid-fuzz.mts", ...["sections/grid/dnd/engine.ts", "layout/geometry.ts", "layout/reflow.ts", "layout/constants.ts"].map( (file) => `apps/nextjs/src/components/board/${file}`, ), ]; const hashes = Object.fromEntries( await Promise.all( sources.map(async (file) => [ file, createHash("sha256") .update(await readFile(path.join(root, file))) .digest("hex"), ]), ), ); let revision = "unknown"; try { revision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { /* A source archive may not contain Git metadata. */ } await mkdir(outputDirectory, { recursive: true }); const artifact = path.join(outputDirectory, `grid-fuzz-${seed}-${Date.now()}-${process.pid}.json`); await writeFile( artifact, `${JSON.stringify({ schemaVersion: 1, seed, steps, revision, hashes, initialState, state, transcript, error: String(error), stack: error instanceof Error ? error.stack : undefined }, null, 2)}\n`, ); console.error( `Grid fuzz failed. Reproduce with the same source revision:\n${usage.split(" [")[0]} --seed ${seed} --steps ${steps}\nArtifact: ${artifact}`, ); throw error; }