From 72b075649eb236ad8e68f66864ea5bfc48674761 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 10 Sep 2026 20:07:14 +0200 Subject: [PATCH] chore: replace grid fuzzing with regression report context --- .github/ISSUE_TEMPLATE/bug_report.yml | 12 + scripts/regressions/README.md | 40 ---- scripts/regressions/grid-fuzz.mts | 301 -------------------------- 3 files changed, 12 insertions(+), 341 deletions(-) delete mode 100644 scripts/regressions/README.md delete mode 100644 scripts/regressions/grid-fuzz.mts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index da7634ca6..d814a62d1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,6 +25,18 @@ body: attributes: label: Additional information description: Add any other information related to the bug here, screenshots if applicable. + - type: input + id: exactBuild + attributes: + label: Exact build or image + description: For preview or development builds, include the commit SHA or image digest. A moving tag such as v2 is not enough to identify the build. + placeholder: "Commit SHA, image digest, or release version" + - type: input + id: lastWorkingVersion + attributes: + label: Last working version + description: If this worked before, include the last version or build where you tried the same action. Leave blank if unknown. + placeholder: "Version or commit SHA" - type: dropdown id: version attributes: diff --git a/scripts/regressions/README.md b/scripts/regressions/README.md deleted file mode 100644 index 246480931..000000000 --- a/scripts/regressions/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Release regression exploration - -Run the opt-in grid fuzzer from the repository root after installing dependencies: - -```sh -pnpm exec tsx --tsconfig apps/nextjs/tsconfig.json scripts/regressions/grid-fuzz.mts --seed 6545 --steps 1000 -``` - -Change the seed to explore another repeatable sequence. `--seed` accepts unsigned -32-bit integers, including zero; `--steps` accepts 1–100,000 transactions. Each -transaction has one to four move or resize previews followed by commit or cancel. -The committed state becomes the next transaction's input. The fixture includes -two root grids and two nested containers with varying widths and row limits. - -The runner calls the real v2 grid engine and checks placement identity and -metadata, integer geometry, bounds, overlap, container ownership, cycles, -immutable snapshots, repeat-preview stability, rejection retention, and exact -cancel/commit results. It does not start Homarr, Docker, or a browser. It uses -the Next.js app's TypeScript config to resolve the engine's existing imports. - -A failure exits nonzero and writes the seed, source revision and hashes, initial -state, and operation transcript to `/tmp/homarr-grid-fuzz`. Use `--output ` -to keep artifacts elsewhere. Re-run the printed command on the same source to -reproduce it; changing engine or runner code can change a seed's outcome. Reduce -`--steps` to find the first failing transaction. Artifacts contain synthetic data. - -Passing this runner covers v2 engine invariants only. It does **not** establish -parity with `dev`, browser rendering, save/reload persistence, or database upgrades. -The next useful differential checks are: - -- Clone a seeded `dev` database, upgrade only its copy to v2, and compare widget - identities/options/integrations through the category/dynamic-to-container - migration (`packages/db/migrations/custom/0004_unify_sections_and_gutters.ts`). -- Drive the same saved board on both versions around layout breakpoints and - compare visible widget counts, errors, containment, and save/reload outcomes. - Use separate authenticated browser sessions and version-specific selectors; - the existing `scripts/benchmarks/dev-board.mts` markers are v2-specific. -- Reuse mock integration fixtures from `e2e/lazy-widgets.spec.ts` and geometry - checks from `e2e/media-server-layout.spec.ts`, keeping intentional v2 redesigns - separate from missing behavior. Pixel equality is not a release criterion. diff --git a/scripts/regressions/grid-fuzz.mts b/scripts/regressions/grid-fuzz.mts deleted file mode 100644 index 4a115907a..000000000 --- a/scripts/regressions/grid-fuzz.mts +++ /dev/null @@ -1,301 +0,0 @@ -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; -}