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

@@ -13,7 +13,8 @@ You can change the user and group by using the `PUID` and `PGID` environment var
Important to note is, that you'll also need to change the permissions for the mounted directories accordingly.
:::info
When starting up an updated version of the container it will have about **10 seconds longer to start** as it has to change the permissions of the app directories
At startup, Homarr updates ownership of its data directories and runtime caches for the selected user and group.
Startup time depends on the amount of persistent data; application code and dependencies do not need ownership changes.
:::
## Without mounted Docker socket
@@ -28,6 +28,8 @@ import { smartHomeExecuteAutomationWidget } from "@site/docs/widgets/smart-home-
]}
/>
Entity toggles and automation triggers send JSON requests to Home Assistant. A rejected action is shown as a failure.
### Adding the integration
<AddingIntegration />
@@ -30,6 +30,8 @@ import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
]}
/>
Disk temperatures are matched to filesystem partitions, including device names without the `/dev/` prefix.
### Adding the integration
<AddingIntegration />
@@ -19,6 +19,9 @@ import Alert from "@theme/Admonition";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
Disk space uses each pool's root dataset to account for RAIDZ parity and dataset limits. If dataset space is unavailable,
Homarr falls back to physical pool space and keeps reporting pool health.
### Adding the integration
<AddingIntegration />
@@ -23,6 +23,8 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
CPU usage is averaged across logical processors, including SMT threads.
### Adding the integration
<AddingIntegration />
@@ -22,6 +22,8 @@ one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
visual density when a low-column dashboard is enlarged to fill the screen.
Right-click a bookmark link in view mode to use the browser menu, including opening it in a new tab or copying its address.
### Screenshots
import gridIconsOnly from "./img/grid-icons-only.png";
@@ -244,7 +244,13 @@ export const WidgetContextMenu = ({
setMenuOpened(false);
}, []);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
if (
!session ||
!settings.enableRightClickOnWidgets ||
isEditMode ||
item.kind === "app" ||
item.kind === "bookmarks"
) {
return <>{children}</>;
}
+2 -2
View File
@@ -9,7 +9,7 @@ http {
# Route websockets traffic to port 3001
location /websockets {
proxy_pass http://${HOSTNAME}:3001;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
@@ -18,7 +18,7 @@ http {
# Route all other traffic to port 3000
location / {
proxy_pass http://${HOSTNAME}:3000;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1 -1
View File
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
return "http://127.0.0.1:3000";
}
export const trpcPath = "/api/trpc";
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
*/
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
headers: {
...this.getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
method: "POST",
});
@@ -158,6 +158,8 @@ const happyResponder = (method: string) => {
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "pool.dataset.query":
return [{ id: "tank", used: { parsed: 500 }, available: { parsed: 500 } }];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
@@ -214,7 +216,7 @@ describe("TrueNasIntegration", () => {
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
// `available` is the free space left on the pool (free = 500), not its total size.
// `available` is the usable free space of the root dataset, not the total pool size.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
@@ -236,10 +238,14 @@ describe("TrueNasIntegration", () => {
expect([...gates.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]);
});
expect(gates.has("reporting.netdata_get_data")).toBe(false);
expect(gates.has("pool.dataset.query")).toBe(false);
gates.get("interface.query")?.resolve(happyResponder("interface.query"));
await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true));
gates.get("pool.query")?.resolve(happyResponder("pool.query"));
await vi.waitFor(() => expect(gates.has("pool.dataset.query")).toBe(true));
for (const [method, gate] of gates) gate.resolve(happyResponder(method));
await expect(resultPromise).resolves.toMatchObject({
@@ -15,6 +15,22 @@ const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
const ERROR_MESSAGE_LIMIT = 200;
/**
* The error decorator replaces a rejected request with a generic message and moves the actionable
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
* reason is truncated before it reaches a log line that repeats on every poll.
*/
const describeRequestError = (error: unknown) => {
const cause = error instanceof Error ? error.cause : undefined;
let reason = String(error);
if (error instanceof Error) reason = error.message;
if (cause instanceof Error) reason = cause.message;
if (reason.length > ERROR_MESSAGE_LIMIT) return `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…`;
return reason;
};
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private client?: TrueNasClient;
@@ -48,12 +64,17 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes,
memUsedInBytes,
fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name,
available: `${dataset.free}`, // free space left on the pool
used: `${dataset.allocated}`,
percentage: (dataset.allocated / dataset.size) * 100,
})),
fileSystem: datasets.map((dataset) => {
const total = dataset.used + dataset.available;
let percentage = 0;
if (total > 0) percentage = (dataset.used / total) * 100;
return {
deviceName: dataset.name,
available: `${dataset.available}`,
used: `${dataset.used}`,
percentage,
};
}),
availablePkgUpdates: 0,
network: {
up: upload * NETWORK_MULTIPLIER,
@@ -108,9 +129,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
name: pool.name,
status: pool.status,
healthy: pool.healthy,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
@@ -118,12 +140,76 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
if (activePools.length === 0) return [];
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
return activePools.map((pool) => {
const usableSpace = usableSpaceByPool.get(pool.name);
if (!usableSpace) {
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
url: this.integration.url,
pool: pool.name,
});
}
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
used: usableSpace?.used ?? pool.allocated,
available: usableSpace?.available ?? pool.free,
};
});
}
/**
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
* See https://github.com/homarr-labs/homarr/issues/6566
*
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
* its physical values instead of dropping it, which would also hide its health status:
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
* - response rejected → the payload does not match the expected shape
* - dataset missing → the pool's root dataset was not returned by the query
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
*/
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
try {
const datasets = await this.requestAsync("pool.dataset.query", [
[["id", "in", poolNames]],
{
extra: {
properties: ["used", "available"],
},
},
]);
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
const used = dataset.used?.parsed;
const available = dataset.available?.parsed;
if (typeof used !== "number" || typeof available !== "number") continue;
usableSpaceByPool.set(dataset.id, { used, available });
}
} catch (error) {
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
url: this.integration.url,
error: describeRequestError(error),
});
}
return usableSpaceByPool;
}
/**
@@ -264,6 +350,19 @@ const poolSchema = z.array(
}),
);
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
// so they are optional here and the pool falls back to its physical values instead of failing
// the whole request, which would also drop cpu, memory and network data
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
const poolDatasetSchema = z.array(
z.object({
id: z.string(),
used: poolDatasetPropertySchema,
available: poolDatasetPropertySchema,
}),
);
const reportingNetDataSchema = z.array(
z.object({
name: z.string(),
@@ -37,7 +37,11 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
const systemInfo = await this.getSystemInformationAsync();
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.info.cpu.cores;
const cpuCount = systemInfo.metrics.cpu.cpus.length;
let cpuUtilizationNormalized = 0;
if (cpuCount > 0) {
cpuUtilizationNormalized = cpuUtilization / cpuCount;
}
const totalMemory = systemInfo.metrics.memory.total;
const usedMemory = Math.max(totalMemory - systemInfo.metrics.memory.available, 0);
@@ -46,7 +50,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
return {
version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilization / cpuCount,
cpuUtilization: cpuUtilizationNormalized,
memUsedInBytes: usedMemory,
memAvailableInBytes: totalMemory - usedMemory,
uptime: dayjs().diff(uptime, "seconds"),
@@ -5,10 +5,16 @@ interface StorageVolumeEntry {
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// SCSI/SATA/VirtIO without device node prefix: sda1 -> sda (OMV reports unprefixed names)
{ pattern: /^((?:sd|vd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// NVMe without device node prefix: nvme0n1p2 -> nvme0n1
{ pattern: /^(nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC without device node prefix: mmcblk0p1 -> mmcblk0
{ pattern: /^(mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
];
export const normalizeStorageDeviceName = (deviceName: string): string => {
+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=$!