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