fix: preserve dev fixes in v2 and add grid fuzzing

This commit is contained in:
ajnart committed 2026-09-10 19:58:53 +02:00
1 parent 7c00c18a42
commit c18548f7c2
18 files changed
+526 -31

No files matched your search

+25 -8
View File
@@ -7,14 +7,31 @@ export PGID=${PGID:-0}
echo "Starting with UID='$PUID', GID='$PGID'"
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
# The below command will change the owner of all files in the /app directory (except node_modules) to the new UID and GID
echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
chown -R $PUID:$PGID /var/cache/nginx
chown -R $PUID:$PGID /var/log/nginx
chown -R $PUID:$PGID /var/lib/nginx
chown -R $PUID:$PGID /run/nginx/nginx.pid
chown -R $PUID:$PGID /etc/nginx
# Only chown the paths the non-root user must actually write to at runtime.
# Recursively chowning the whole /app tree is extremely slow on network- or
# HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone
# bundle and its node_modules contain tens of thousands of tiny files. This
# turned a ~10s step into 15+ minutes for some users (#2190). Application
# code, migrations and native modules are only read/executed, so they stay
# owned by root and do not need chowning.
echo "Changing owner to $PUID:$PGID..."
# Application data volume (db, redis dumps, trusted certificates).
# Ensure the known subdirectories exist and migrate ownership of any
# existing content, so a persistent volume with stale or root-owned
# entries stays writable after a PUID/PGID change. These trees are small
# (a single sqlite db, a redis dump, a few certs), unlike the /app tree.
mkdir -p /appdata/db /appdata/redis /appdata/trusted-certificates
chown "${PUID}:${PGID}" /appdata
chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates
# Next.js runtime cache (image optimization, fetch cache, ...)
mkdir -p /app/apps/nextjs/.next/cache
chown -R "${PUID}:${PGID}" /app/apps/nextjs/.next/cache
# nginx runtime directories and the rendered configuration
chown -R "${PUID}:${PGID}" /var/cache/nginx
chown -R "${PUID}:${PGID}" /var/log/nginx
chown -R "${PUID}:${PGID}" /var/lib/nginx
chown -R "${PUID}:${PGID}" /run/nginx/nginx.pid
chown -R "${PUID}:${PGID}" /etc/nginx
echo "Changing owner to $PUID:$PGID, done."
fi
+40
View File
@@ -0,0 +1,40 @@
# 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
@@ -0,0 +1,301 @@
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;
}
+6 -5
View File
@@ -21,20 +21,18 @@ fi
export AUTH_SECRET=$(openssl rand -base64 32)
# Start nginx proxy
# 1. Replace the HOSTNAME in the nginx template file
# 2. Create the nginx configuration file from the template
# 3. Start the nginx server
# 1. Create the nginx configuration file from the template
# 2. Start the nginx server
# Only listen on IPv6 when the host actually has IPv6 configured (#4596).
# Otherwise nginx aborts with "socket() [::]:7575 failed (97: Address family
# not supported by protocol)". Check HOMARR_DISABLE_IPV6 first so users can
# force IPv4-only even when the host supports IPv6, then probe for IPv6
# addresses via grep (not -s: proc files always report size 0).
export HOSTNAME
export NGINX_LISTEN_IPV6=''
if [ "${HOMARR_DISABLE_IPV6:-false}" != "true" ] && grep -q . /proc/net/if_inet6 2>/dev/null; then
NGINX_LISTEN_IPV6='listen [::]:7575;'
fi
envsubst '${HOSTNAME} ${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf
envsubst '${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf
# Start services in the background and store their PIDs
nginx -g 'daemon off;' &
NGINX_PID=$!
@@ -67,6 +65,9 @@ terminate() {
trap terminate TERM INT
# Next.js standalone uses HOSTNAME as its bind address. Docker's generated
# hostname can be unresolvable, so bind explicitly while nginx uses loopback.
export HOSTNAME=0.0.0.0
node apps/nextjs/server.js &
NEXTJS_PID=$!