fix: preserve dev fixes in v2 and add grid fuzzing
This commit is contained in:
1 parent
7c00c18a42
commit
c18548f7c2
18 files changed
+526
-31
No files matched your search
+25
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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=$!
|
||||
|
||||
|
||||
Reference in new issue
Block a user