Files
homarr/scripts/regressions/grid-fuzz.mts
T

302 lines
11 KiB
TypeScript

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;
}