chore: replace grid fuzzing with regression report context

This commit is contained in:
ajnart committed 2026-09-10 20:07:14 +02:00
1 parent c18548f7c2
commit 72b075649e
3 files changed
+12 -341

No files matched your search

+12
View File
@@ -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:
-40
View File
@@ -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 <path>`
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.
-301
View File
@@ -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<string, string>();
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 = <T,>(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<Placement>;
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<Placement>, 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<string, number> };
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;
}