diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d8dd4e0..86b1ec5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,9 @@ jobs: - name: Validate README documentation run: pnpm run test:validate + + - name: Run memory tests (Node, --expose-gc) + run: pnpm run test:memory:node + + - name: Run memory tests (browser, Chromium gc()) + run: pnpm run test:memory:browser diff --git a/package.json b/package.json index 4c886f7c..a8e0d2e4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "scripts": { "dev": "vitepress dev docs", "test": "vitest", + "test:memory": "pnpm run test:memory:node && pnpm run test:memory:browser", + "test:memory:node": "vitest run --config vitest.memory.node.config.ts", + "test:memory:browser": "vitest run --config vitest.memory.browser.config.ts", + "test:memory:soak": "vitest run --config vitest.memory.soak.config.ts", "test:validate": "pnpm --filter @supergrain/doc-tests run test:validate", "build": "pnpm --filter=\"@supergrain/kernel\" --filter=\"@supergrain/mill\" --filter=\"@supergrain/silo\" --workspace-concurrency=1 run build", "bench:core": "vitest bench --root packages/kernel", @@ -23,7 +27,8 @@ "release": "pnpm -r publish", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs" + "docs:preview": "vitepress preview docs", + "playwright:install": "playwright install chromium" }, "dependencies": { "alien-signals": "^2.0.7" @@ -34,8 +39,8 @@ "@testing-library/react": "^16.3.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", - "@vitest/browser": "^4.1.0", - "@vitest/browser-playwright": "^4.1.0", + "@vitest/browser": "^4.1.5", + "@vitest/browser-playwright": "^4.1.5", "jsdom": "^26.1.0", "oxfmt": "^0.41.0", "oxlint": "^1.15.0", @@ -48,7 +53,7 @@ "vite": "^7.1.5", "vite-plugin-dts": "^4.5.4", "vitepress": "^1.6.4", - "vitest": "^4.1.0" + "vitest": "^4.1.5" }, "packageManager": "pnpm@10.6.3" } diff --git a/packages/comparisons/package.json b/packages/comparisons/package.json index efe0202d..b5f2fec5 100644 --- a/packages/comparisons/package.json +++ b/packages/comparisons/package.json @@ -19,7 +19,7 @@ "zustand": "^5.0.8" }, "devDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" } } diff --git a/packages/doc-tests/package.json b/packages/doc-tests/package.json index f3ad3a83..46fd904c 100644 --- a/packages/doc-tests/package.json +++ b/packages/doc-tests/package.json @@ -30,12 +30,12 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "4.1.0", + "@vitest/browser": "4.1.5", "playwright": "^1.55.0", "react": "^19.1.1", "react-dom": "^19.1.1", "typescript": "^5.9.2", "vite": "^7.1.5", - "vitest": "4.1.0" + "vitest": "4.1.5" } } diff --git a/packages/husk/package.json b/packages/husk/package.json index 1f5ee723..081599f5 100644 --- a/packages/husk/package.json +++ b/packages/husk/package.json @@ -58,18 +58,19 @@ "@supergrain/kernel": "workspace:*" }, "devDependencies": { + "@supergrain/test-utils": "workspace:*", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "4.1.0", + "@vitest/browser": "4.1.5", "playwright": "^1.55.0", "react": "^19.1.1", "react-dom": "^19.1.1", "typescript": "^5.9.2", "vite": "^7.1.5", - "vitest": "4.1.0" + "vitest": "4.1.5" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0" diff --git a/packages/husk/tests/memory/fixtures.ts b/packages/husk/tests/memory/fixtures.ts new file mode 100644 index 00000000..caa10f2e --- /dev/null +++ b/packages/husk/tests/memory/fixtures.ts @@ -0,0 +1,121 @@ +import { signal } from "@supergrain/kernel"; +import { delay } from "@supergrain/test-utils/memory"; + +import { defineResource, dispose, reactivePromise, reactiveTask, resource } from "../../src"; + +export interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export interface HuskPayload { + id: number; + values: Array; +} + +export function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +export function makePayload(seed: number, width = 24): Array { + return Array.from({ length: width }, (_, index) => ({ + id: seed * 1_000 + index, + values: Array.from({ length: 18 }, (__, offset) => seed + index + offset), + })); +} + +export async function runHuskCycle(seed: number): Promise { + const resourceTrigger = signal(0); + const resourceDeferreds: Array> = []; + const asyncResource = resource( + { value: 0, payload: makePayload(seed) }, + async (state, { abortSignal }) => { + const current = resourceTrigger(); + const run = deferred(); + resourceDeferreds.push(run); + const value = await run.promise; + if (abortSignal.aborted) return; + state.value = current + value; + state.payload = makePayload(seed + value); + }, + ); + + resourceTrigger(1); + + const promiseTrigger = signal(0); + const promiseDeferreds: Array }>> = []; + const reactive = reactivePromise(async (abortSignal) => { + const current = promiseTrigger(); + const run = deferred<{ value: number; payload: Array }>(); + abortSignal.addEventListener("abort", () => + run.resolve({ value: current, payload: makePayload(seed) }), + ); + promiseDeferreds.push(run); + return run.promise; + }); + + promiseTrigger(1); + promiseTrigger(2); + + const task = reactiveTask(async (mode: "ok" | "fail", value: number) => { + if (mode === "fail") throw new Error(`task-${value}`); + return { value, payload: makePayload(seed + value, 12) }; + }); + + const okRun = task.run("ok", seed); + const failedRun = task.run("fail", seed).catch(() => undefined); + + dispose(asyncResource); + dispose(reactive as object); + + for (const run of resourceDeferreds) { + run.resolve(seed); + } + for (const run of promiseDeferreds) { + run.resolve({ value: seed, payload: makePayload(seed + 1, 16) }); + } + + await Promise.allSettled([ + okRun, + failedRun, + ...resourceDeferreds.map((run) => run.promise), + ...promiseDeferreds.map((run) => run.promise), + ]); + await delay(); +} + +/** + * Exercises defineResource — reusable factory that creates multiple + * independent instances and disposes them all. + */ +export async function runDefineResourceCycle(seed: number): Promise { + const fetchData = defineResource }>( + () => ({ value: 0, payload: makePayload(seed) }), + async (state, url, { abortSignal }) => { + const run = deferred>(); + abortSignal.addEventListener("abort", () => run.resolve([])); + const result = await run.promise; + if (abortSignal.aborted) return; + state.value = url + result.length; + state.payload = makePayload(seed + url); + }, + ); + + const trigger = signal(seed); + const instances = Array.from({ length: 5 }, () => fetchData(() => trigger())); + + trigger(seed + 1); + trigger(seed + 2); + + for (const instance of instances) { + dispose(instance); + } + await delay(); +} diff --git a/packages/husk/tests/memory/husk.memory.soak.spec.ts b/packages/husk/tests/memory/husk.memory.soak.spec.ts new file mode 100644 index 00000000..afa9b299 --- /dev/null +++ b/packages/husk/tests/memory/husk.memory.soak.spec.ts @@ -0,0 +1,29 @@ +import { + HAS_GC, + assertGcAvailable, + collectHeapSamples, + expectTrendToFlatten, +} from "@supergrain/test-utils/memory"; +import { describe, it } from "vitest"; + +import { runHuskCycle } from "./fixtures"; + +it("GC is exposed (required for husk soak)", () => { + assertGcAvailable(); +}); + +describe.runIf(HAS_GC)("husk memory soak", () => { + it("stays flat during extended async rerun churn", async () => { + const samples = await collectHeapSamples(10, async (round) => { + for (let index = 0; index < 100; index++) { + await runHuskCycle(round * 10_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 5_000_000, + maxLastDeltaBytes: 850_000, + maxTailHeadRatio: 2.0, + }); + }); +}); diff --git a/packages/husk/tests/memory/husk.memory.spec.ts b/packages/husk/tests/memory/husk.memory.spec.ts new file mode 100644 index 00000000..cfcb7fd7 --- /dev/null +++ b/packages/husk/tests/memory/husk.memory.spec.ts @@ -0,0 +1,238 @@ +import { signal } from "@supergrain/kernel"; +import { + HAS_GC, + assertGcAvailable, + collectHeapSamples, + delay, + expectCollectible, + expectRetainedHeapBudget, + expectTrendToFlatten, +} from "@supergrain/test-utils/memory"; +import { describe, it } from "vitest"; + +import { defineResource, dispose, reactivePromise, resource } from "../../src"; +import { + type Deferred, + type HuskPayload, + deferred, + makePayload, + runDefineResourceCycle, + runHuskCycle, +} from "./fixtures"; + +// Always-run sentinel: ensures the memory config actually exposed GC. +it("GC is exposed (required for all husk memory tests)", () => { + assertGcAvailable(); +}); + +describe.runIf(HAS_GC)("husk memory", () => { + it("collects disposed resources after async cleanup races", async () => { + await expectCollectible(async () => { + const trigger = signal(0); + const pending: Array> = []; + const trackedResource = resource( + { value: 0, payload: makePayload(1, 8) }, + async (state, { abortSignal }) => { + const current = trigger(); + const run = deferred(); + pending.push(run); + const value = await run.promise; + if (abortSignal.aborted) return; + state.value = current + value; + state.payload = makePayload(value, 8); + }, + ); + + trigger(1); + + return { + targets: [trackedResource as object, trackedResource.payload[0] as object], + teardown: () => dispose(trackedResource), + settle: async () => { + for (const run of pending) { + run.resolve(1); + } + await Promise.allSettled(pending.map((run) => run.promise)); + await delay(); + }, + }; + }); + }); + + it("collects resource disposed BEFORE any deferred resolves (abort-before-resolve)", async () => { + await expectCollectible(async () => { + const trigger = signal(0); + const pending: Array> = []; + const trackedResource = resource( + { value: 0, payload: makePayload(10, 6) }, + async (state, { abortSignal }) => { + const run = deferred(); + abortSignal.addEventListener("abort", () => run.resolve(0)); + pending.push(run); + const value = await run.promise; + if (abortSignal.aborted) return; + state.value = value; + }, + ); + + trigger(1); + // Dispose immediately, before any deferred has resolved + dispose(trackedResource); + + return { + targets: [trackedResource as object], + settle: async () => { + for (const run of pending) run.resolve(0); + await Promise.allSettled(pending.map((run) => run.promise)); + await delay(); + }, + }; + }); + }); + + it("collects disposed reactivePromise envelopes after stale runs settle", async () => { + await expectCollectible(async () => { + const trigger = signal(0); + const pending: Array }>> = []; + const reactive = reactivePromise(async (abortSignal) => { + const current = trigger(); + const run = deferred<{ value: number; payload: Array }>(); + abortSignal.addEventListener("abort", () => + run.resolve({ value: current, payload: makePayload(current, 8) }), + ); + pending.push(run); + return run.promise; + }); + + trigger(1); + + return { + targets: [reactive as object], + teardown: () => dispose(reactive as object), + settle: async () => { + for (const run of pending) { + run.resolve({ value: 1, payload: makePayload(2, 8) }); + } + await Promise.allSettled(pending.map((run) => run.promise)); + await delay(); + }, + }; + }); + }); + + it("collects defineResource instances after all are disposed", async () => { + await expectCollectible(async () => { + const fetchData = defineResource }>( + () => ({ value: 0, payload: makePayload(42) }), + async (state, _url, { abortSignal }) => { + const run = deferred(); + abortSignal.addEventListener("abort", () => run.resolve(0)); + const v = await run.promise; + if (abortSignal.aborted) return; + state.value = v; + }, + ); + + const trigger = signal(1); + const inst = fetchData(() => trigger()); + trigger(2); + + return { + targets: [inst as object], + teardown: () => dispose(inst), + settle: () => delay(), + }; + }); + }); + + // Targeted abort-listener leak test. Resources register addEventListener("abort") + // on the AbortSignal; if those listeners aren't released when the resource is + // disposed cleanly (no abort fired), the signal accumulates listeners across + // many disposal cycles. We hold a single AbortController across N resources + // so any listener leak is observable as growing retention against that + // controller's signal. + it("does not leak abort listeners across many resource lifecycles sharing one signal", async () => { + await expectRetainedHeapBudget(async () => { + const sharedController = new AbortController(); + const sharedSignal = sharedController.signal; + for (let index = 0; index < 200; index++) { + const trigger = signal(0); + const pending: Array> = []; + const r = resource( + { value: 0, payload: makePayload(index, 4) }, + async (state, { abortSignal }) => { + sharedSignal.addEventListener("abort", () => undefined); + abortSignal.addEventListener("abort", () => undefined); + const run = deferred(); + pending.push(run); + const v = await run.promise; + if (abortSignal.aborted) return; + state.value = v; + }, + ); + trigger(1); + dispose(r); + for (const run of pending) run.resolve(index); + await Promise.allSettled(pending.map((run) => run.promise)); + await delay(); + } + sharedController.abort(); + }, 3_000_000); + }); + + it("keeps retained heap bounded across repeated async abort, cleanup, and task churn", async () => { + await expectRetainedHeapBudget(async () => { + for (let index = 0; index < 120; index++) { + await runHuskCycle(index); + } + }, 3_500_000); + }); + + // High-N retention test for the racy resource/promise/task cycle. If any + // path retains references at a per-cycle linear rate this blows past budget; + // bounded retention proves the cleanup paths actually run. + it("retained heap stays sublinear across 600 async cycles", async () => { + await expectRetainedHeapBudget(async () => { + for (let index = 0; index < 600; index++) { + await runHuskCycle(index); + } + }, 6_500_000); + }); + + it("keeps retained heap bounded across repeated defineResource factory churn", async () => { + await expectRetainedHeapBudget(async () => { + for (let index = 0; index < 80; index++) { + await runDefineResourceCycle(index); + } + }, 3_000_000); + }); + + it("flattens retained heap across repeated async rounds", async () => { + const samples = await collectHeapSamples(8, async (round) => { + for (let index = 0; index < 60; index++) { + await runHuskCycle(round * 1_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 3_500_000, + maxLastDeltaBytes: 600_000, + maxTailHeadRatio: 1.8, + }); + }); + + it("flattens retained heap across repeated defineResource factory rounds", async () => { + const samples = await collectHeapSamples(8, async (round) => { + for (let index = 0; index < 40; index++) { + await runDefineResourceCycle(round * 1_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 3_000_000, + maxLastDeltaBytes: 500_000, + maxTailHeadRatio: 1.8, + maxConsecutiveGrowthRounds: 4, + }); + }); +}); diff --git a/packages/husk/tests/react/husk.memory.spec.tsx b/packages/husk/tests/react/husk.memory.spec.tsx new file mode 100644 index 00000000..4d302fa6 --- /dev/null +++ b/packages/husk/tests/react/husk.memory.spec.tsx @@ -0,0 +1,78 @@ +import { tracked } from "@supergrain/kernel/react"; +import { collectBrowserSamples, expectBrowserTrend } from "@supergrain/test-utils/browser-memory"; +import { cleanup, render, act } from "@testing-library/react"; +import { afterEach, describe, it } from "vitest"; + +import { useReactivePromise, useResource } from "../../src/react"; + +afterEach(() => cleanup()); + +interface HuskPayload { + label: string; + values: Array; +} + +function makePayload(seed: number, width = 18): Array { + return Array.from({ length: width }, (_, index) => ({ + label: `payload-${seed}-${index}`, + values: Array.from({ length: 14 }, (__, offset) => seed + index + offset), + })); +} + +const HuskHarness = tracked(function HuskHarness({ seed }: { seed: number }) { + const resourceState = useResource({ cursor: 0, payload: makePayload(seed) }, (state) => { + state.payload = makePayload(seed); + return () => { + state.payload = []; + }; + }); + + const promiseState = useReactivePromise(async (abortSignal) => { + const current = resourceState.cursor; + await Promise.resolve(); + if (abortSignal.aborted) return { label: "aborted", total: -1 }; + return { + label: resourceState.payload[current]!.label, + total: resourceState.payload[current]!.values.reduce((sum, value) => sum + value, 0), + }; + }); + + return ( + + ); +}); + +describe("husk react memory", () => { + // The racy unmount-during-async case. The realistic-app test in silo waits + // for async to settle between actions so it never quite hits this race + // organically. This test specifically forces unmount before the in-flight + // useReactivePromise resolves, validating the abort path's cleanup. + it("keeps Chromium heap flat when component unmounts while async is still pending", async () => { + const samples = await collectBrowserSamples(6, async (round) => { + for (let index = 0; index < 25; index++) { + const view = render(); + // Click without waiting — unmount races the in-flight promise + view.getByTestId("husk-memory").click(); + view.unmount(); + // Drain microtasks so aborted promises settle + await act(async () => { + await Promise.resolve(); + }); + } + cleanup(); + }); + + expectBrowserTrend(samples, { + maxGrowthBytes: 4_500_000, + maxLastDeltaBytes: 1_000_000, + }); + }); +}); diff --git a/packages/js-krauset-main/package.json b/packages/js-krauset-main/package.json index 37adee97..35397e31 100644 --- a/packages/js-krauset-main/package.json +++ b/packages/js-krauset-main/package.json @@ -23,13 +23,13 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "4.1.0", + "@vitest/browser": "4.1.5", "playwright": "^1.55.0", "ramda": "^0.32.0", "typescript": "^5.9.2", "vite": "^7.1.5", "vite-plugin-dts": "^4.5.4", - "vitest": "4.1.0" + "vitest": "4.1.5" }, "js-framework-benchmark": { "frameworkVersionFromPackage": "react", diff --git a/packages/js-krauset-react-hooks/package.json b/packages/js-krauset-react-hooks/package.json index bc2ab96b..60bc1fbf 100644 --- a/packages/js-krauset-react-hooks/package.json +++ b/packages/js-krauset-react-hooks/package.json @@ -25,13 +25,13 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "4.1.0", + "@vitest/browser": "4.1.5", "playwright": "^1.55.0", "ramda": "^0.32.0", "typescript": "^5.9.2", "vite": "^7.1.5", "vite-plugin-dts": "^4.5.4", - "vitest": "4.1.0" + "vitest": "4.1.5" }, "js-framework-benchmark": { "frameworkVersionFromPackage": "react", diff --git a/packages/js-krauset/package.json b/packages/js-krauset/package.json index e15beb27..2ee16c96 100644 --- a/packages/js-krauset/package.json +++ b/packages/js-krauset/package.json @@ -25,13 +25,13 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "4.1.0", + "@vitest/browser": "4.1.5", "playwright": "^1.55.0", "ramda": "^0.32.0", "typescript": "^5.9.2", "vite": "^7.1.5", "vite-plugin-dts": "^4.5.4", - "vitest": "4.1.0" + "vitest": "4.1.5" }, "js-framework-benchmark": { "frameworkVersionFromPackage": "react", diff --git a/packages/kernel/package.json b/packages/kernel/package.json index 533f70ff..548dd300 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -76,13 +76,14 @@ "@rollup/plugin-strip": "^3.0.4", "@solidjs/testing-library": "^0.8.10", "@supergrain/mill": "workspace:*", + "@supergrain/test-utils": "workspace:*", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/node": "^22.18.3", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "4.1.0", + "@vitest/browser": "4.1.5", "arktype": "^2.2.0", "jotai": "^2.14.0", "magic-string": "^0.30.17", @@ -94,7 +95,7 @@ "typescript": "^5.9.2", "valtio": "^2.1.7", "vite": "^7.1.5", - "vitest": "4.1.0", + "vitest": "4.1.5", "zustand": "^5.0.8" }, "peerDependencies": { diff --git a/packages/kernel/tests/memory/fixtures.ts b/packages/kernel/tests/memory/fixtures.ts new file mode 100644 index 00000000..1b2a9192 --- /dev/null +++ b/packages/kernel/tests/memory/fixtures.ts @@ -0,0 +1,104 @@ +import { createReactive, effect } from "../../src"; + +export interface KernelLeaf { + id: number; + label: string; + values: Array; +} + +export function makeLeaves(seed: number, width = 24): Array { + return Array.from({ length: width }, (_, index) => ({ + id: seed * 1_000 + index, + label: `leaf-${seed}-${index}`, + values: Array.from({ length: 16 }, (__, offset) => seed + index + offset), + })); +} + +export function runKernelCycle(seed: number): void { + const state = createReactive({ + cursor: 0, + leaves: makeLeaves(seed), + nested: { depth: seed, flags: Array.from({ length: 8 }, (_, index) => index % 2 === 0) }, + }); + + const stop = effect(() => { + const current = state.leaves[state.cursor]!; + void current.label; + void current.values.reduce((sum, value) => sum + value, 0); + void state.nested.depth; + void state.nested.flags.filter(Boolean).length; + }); + + state.cursor = state.leaves.length - 1; + state.leaves[0]!.label = `updated-${seed}`; + state.nested.depth += 1; + state.nested.flags = [...state.nested.flags].reverse(); + stop(); +} + +/** + * Exercises array mutation methods with varying array shapes to stress + * the batch/version-signal path used by array mutators. + */ +export function runArrayShapeCycle(seed: number): void { + // Varies array length on each cycle (seed % 12 || 4 gives values 1-11 with + // 4 as the fallback when seed is a multiple of 12) to exercise different + // batch/version-signal code paths across both short and longer arrays. + const state = createReactive({ + items: Array.from({ length: seed % 12 || 4 }, (_, index) => ({ + id: index, + value: seed + index, + })), + }); + + let sum = 0; + const stop = effect(() => { + sum = state.items.reduce((acc, item) => acc + item.value, 0); + }); + + state.items.push({ id: 999, value: seed }); + state.items.pop(); + state.items.splice(0, 1, { id: -1, value: seed * 2 }); + state.items.sort((a, b) => a.value - b.value); + state.items.reverse(); + void sum; + stop(); +} + +/** + * Stresses nested-proxy read paths: many deeply nested objects accessed + * inside an effect that is then discarded. + */ +export function runNestedReadCycle(seed: number): void { + type Nested = { value: number; child?: Nested }; + + function makeNested(depth: number, base: number): Nested { + return depth === 0 + ? { value: base } + : { value: base + depth, child: makeNested(depth - 1, base) }; + } + + const state = createReactive({ + root: makeNested(6, seed), + list: Array.from({ length: 10 }, (_, index) => makeNested(3, seed + index)), + }); + + const stop = effect(() => { + let n: Nested | undefined = state.root; + while (n) { + void n.value; + n = n.child; + } + for (const item of state.list) { + void item.value; + void item.child?.value; + } + }); + + // Mutate some nodes to exercise write paths too + state.root.value = seed + 100; + if (state.root.child) { + state.root.child.value = seed + 200; + } + stop(); +} diff --git a/packages/kernel/tests/memory/kernel.memory.soak.spec.ts b/packages/kernel/tests/memory/kernel.memory.soak.spec.ts new file mode 100644 index 00000000..cf7fe324 --- /dev/null +++ b/packages/kernel/tests/memory/kernel.memory.soak.spec.ts @@ -0,0 +1,29 @@ +import { + HAS_GC, + assertGcAvailable, + collectHeapSamples, + expectTrendToFlatten, +} from "@supergrain/test-utils/memory"; +import { describe, it } from "vitest"; + +import { runKernelCycle } from "./fixtures"; + +it("GC is exposed (required for kernel soak)", () => { + assertGcAvailable(); +}); + +describe.runIf(HAS_GC)("kernel memory soak", () => { + it("stays flat during extended array and subscription churn", async () => { + const samples = await collectHeapSamples(10, (round) => { + for (let index = 0; index < 160; index++) { + runKernelCycle(round * 10_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 4_000_000, + maxLastDeltaBytes: 700_000, + maxTailHeadRatio: 2.0, + }); + }); +}); diff --git a/packages/kernel/tests/memory/kernel.memory.spec.ts b/packages/kernel/tests/memory/kernel.memory.spec.ts new file mode 100644 index 00000000..e2188d0f --- /dev/null +++ b/packages/kernel/tests/memory/kernel.memory.spec.ts @@ -0,0 +1,210 @@ +import { + HAS_GC, + assertGcAvailable, + collectHeapSamples, + expectCollectible, + expectRetainedHeapBudget, + expectTrendToFlatten, +} from "@supergrain/test-utils/memory"; +import { describe, it } from "vitest"; + +import { createReactive, effect } from "../../src"; +import { makeLeaves, runArrayShapeCycle, runKernelCycle, runNestedReadCycle } from "./fixtures"; + +// Always-run sentinel: ensures the memory config actually exposed GC. +// When running under `pnpm test:memory:node` this must pass; if it fails, +// all the `describe.runIf(HAS_GC)` suites below would silently skip and +// give false confidence. +it("GC is exposed (required for all kernel memory tests)", () => { + assertGcAvailable(); +}); + +describe.runIf(HAS_GC)("kernel memory", () => { + it("collects reactive roots, nested proxies, and subscriptions after teardown", async () => { + await expectCollectible(() => { + const raw = { + leaves: makeLeaves(1, 8), + nested: { depth: 1, active: true }, + }; + const state = createReactive(raw); + const child = state.leaves[0]!; + const stop = effect(() => { + void state.leaves[0]!.label; + void state.nested.depth; + }); + + state.leaves[0]!.label = "collect-me"; + state.nested.depth = 2; + + return { + targets: [raw, state as object, child as object], + teardown: () => stop(), + }; + }); + }); + + // Cross-proxy leak surface: an effect over proxy A reads from proxy B. + // The effect closes over both proxies' signals, so disposing only one of + // them must still release both once the effect itself is stopped. + it("collects both proxies when a cross-proxy effect is stopped", async () => { + await expectCollectible(() => { + const rawA = { value: 1, label: "a" }; + const rawB = { value: 10, items: [1, 2, 3] }; + const stateA = createReactive(rawA); + const stateB = createReactive(rawB); + // Effect over A reads from BOTH A and B — a real-world pattern when one + // store derives state from another. + const stop = effect(() => { + void stateA.value; + void stateB.value; + void stateB.items.length; + }); + + stateA.value = 2; + stateB.value = 11; + stateB.items.push(4); + + return { + targets: [rawA, rawB, stateA as object, stateB as object], + teardown: () => stop(), + }; + }); + }); + + it("collects proxy when multiple effects subscribe and all are stopped", async () => { + await expectCollectible(() => { + const raw = { value: 1, label: "a", items: [1, 2, 3] }; + const state = createReactive(raw); + + const stops = [ + effect(() => { + void state.value; + }), + effect(() => { + void state.label; + }), + effect(() => { + void state.items.length; + }), + effect(() => { + void state.value; + void state.label; + }), + ]; + + state.value = 2; + state.label = "b"; + state.items.push(4); + + return { + targets: [raw, state as object], + teardown: () => { + for (const stop of stops) stop(); + }, + }; + }); + }); + + it("keeps retained heap bounded across repeated proxy and effect churn", async () => { + await expectRetainedHeapBudget(() => { + for (let index = 0; index < 180; index++) { + runKernelCycle(index); + } + }, 2_500_000); + }); + + it("keeps retained heap bounded across repeated array-shape churn", async () => { + await expectRetainedHeapBudget(() => { + for (let index = 0; index < 200; index++) { + runArrayShapeCycle(index); + } + }, 2_500_000); + }); + + // High-N retention test: validates that the per-round heap drift the trend + // tests showed (~50KB/round positive deltas) actually amortizes — the same + // cycle run 8x more times must still fit under a 2x budget, not 8x. If a + // real leak existed this would blow the budget; if it's V8 noise, retention + // amortizes with cycle count and stays bounded. + it("retained heap stays sublinear with cycle count (1500 cycles)", async () => { + await expectRetainedHeapBudget(() => { + for (let index = 0; index < 1500; index++) { + runKernelCycle(index); + } + }, 5_000_000); + }); + + // Long-lived state: a real app holds one reactive store and continuously + // attaches/detaches effects against it. Validates that the per-effect + // disposal path doesn't accumulate references on the long-lived state. + it("long-lived state stays bounded under continuous effect churn", async () => { + await expectRetainedHeapBudget(() => { + const state = createReactive({ + cursor: 0, + items: Array.from({ length: 32 }, (_, index) => ({ id: index, value: index })), + }); + const stops: Array<() => void> = []; + for (let index = 0; index < 800; index++) { + const stop = effect(() => { + void state.items[state.cursor % state.items.length]!.value; + }); + stops.push(stop); + state.cursor = (state.cursor + 1) % state.items.length; + if (index % 4 === 0) { + // Detach the oldest few effects, simulating real subscriber churn. + stops.splice(0, 4).forEach((dispose) => dispose()); + } + } + for (const stop of stops) stop(); + }, 3_500_000); + }); + + it("flattens retained heap across repeated proxy churn rounds", async () => { + const samples = await collectHeapSamples(8, (round) => { + for (let index = 0; index < 80; index++) { + runKernelCycle(round * 1_000 + index); + } + }); + + // Absolute budget + tail/head ratio is the robust pair. Per-round delta + // counts (maxPositiveDeltas, maxConsecutiveGrowthRounds) are too sensitive + // to V8's small monotonic noise across rounds — they fire on healthy cycles + // when the heap drifts upward by a few KB before plateauing. The bounded + // total + sublinear scaling tests above are what catch real leaks. + expectTrendToFlatten(samples, { + maxGrowthBytes: 2_500_000, + maxLastDeltaBytes: 450_000, + maxTailHeadRatio: 1.8, + }); + }); + + it("flattens retained heap across repeated array-shape churn rounds", async () => { + const samples = await collectHeapSamples(8, (round) => { + for (let index = 0; index < 80; index++) { + runArrayShapeCycle(round * 1_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 2_500_000, + maxLastDeltaBytes: 450_000, + maxTailHeadRatio: 1.8, + maxConsecutiveGrowthRounds: 4, + }); + }); + + it("flattens retained heap across repeated nested-proxy read churn rounds", async () => { + const samples = await collectHeapSamples(8, (round) => { + for (let index = 0; index < 60; index++) { + runNestedReadCycle(round * 1_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 2_500_000, + maxLastDeltaBytes: 450_000, + maxTailHeadRatio: 1.8, + maxConsecutiveGrowthRounds: 4, + }); + }); +}); diff --git a/packages/kernel/tests/react/kernel.memory.spec.tsx b/packages/kernel/tests/react/kernel.memory.spec.tsx new file mode 100644 index 00000000..ac3f8f72 --- /dev/null +++ b/packages/kernel/tests/react/kernel.memory.spec.tsx @@ -0,0 +1,118 @@ +import { collectBrowserSamples, expectBrowserTrend } from "@supergrain/test-utils/browser-memory"; +import { cleanup, render, act, within } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, it } from "vitest"; + +import { tracked, useReactive } from "../../src/react"; + +afterEach(() => cleanup()); + +interface BrowserLeaf { + label: string; + values: Array; +} + +function makePayload(seed: number, width = 20): Array { + return Array.from({ length: width }, (_, index) => ({ + label: `leaf-${seed}-${index}`, + values: Array.from({ length: 16 }, (__, offset) => seed + index + offset), + })); +} + +const KernelHarness = tracked(function KernelHarness({ seed }: { seed: number }) { + const state = useReactive({ + cursor: 0, + leaves: makePayload(seed), + }); + + return ( + + ); +}); + +describe("kernel react memory", () => { + it("keeps Chromium heap flat across repeated mount and unmount churn", async () => { + // 6 rounds x 30 mounts = 180 mount/unmount cycles. Larger N catches slow + // leaks that 5x10 would miss while staying under the 30s test timeout. + const samples = await collectBrowserSamples(6, async (round) => { + for (let index = 0; index < 30; index++) { + const view = render(); + await act(async () => { + view.getByTestId("kernel-memory").click(); + view.getByTestId("kernel-memory").click(); + }); + view.unmount(); + } + cleanup(); + }); + + expectBrowserTrend(samples, { + maxGrowthBytes: 4_500_000, + maxLastDeltaBytes: 900_000, + }); + }); + + it("keeps Chromium heap flat across StrictMode double-mount churn", async () => { + // StrictMode is 2x effective mounts, so smaller per-round count. + // 6 rounds x 20 = 240 effective mount cycles. + const samples = await collectBrowserSamples(6, async (round) => { + for (let index = 0; index < 20; index++) { + const view = render( + + + , + ); + await act(async () => { + view.getByTestId("kernel-memory").click(); + }); + view.unmount(); + } + cleanup(); + }); + + expectBrowserTrend(samples, { + maxGrowthBytes: 4_500_000, + maxLastDeltaBytes: 1_000_000, + }); + }); + + it("keeps Chromium heap flat with concurrent component trees", async () => { + const samples = await collectBrowserSamples(5, async (round) => { + for (let index = 0; index < 4; index++) { + const base = round * 400 + index * 4; + const views = [ + render(), + render(), + render(), + render(), + ]; + await act(async () => { + for (const view of views) { + // Scope to view.container — testing-library's default queries bind + // to document.body, which would match buttons across all views. + within(view.container).getByTestId("kernel-memory").click(); + } + }); + for (const view of views) { + view.unmount(); + } + } + cleanup(); + }); + + expectBrowserTrend(samples, { + maxGrowthBytes: 4_000_000, + maxLastDeltaBytes: 900_000, + maxTailHeadRatio: 1.8, + }); + }); +}); diff --git a/packages/mill/package.json b/packages/mill/package.json index 0dc9a391..715956dc 100644 --- a/packages/mill/package.json +++ b/packages/mill/package.json @@ -53,7 +53,7 @@ "alien-signals": "^2.0.7" }, "devDependencies": { - "vitest": "4.1.0" + "vitest": "4.1.5" }, "size-limit": [ { diff --git a/packages/queries/package.json b/packages/queries/package.json index ca26b77b..e914bba9 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -53,9 +53,10 @@ "@supergrain/silo": "workspace:*" }, "devDependencies": { - "@vitest/browser": "4.1.0", - "@vitest/browser-playwright": "4.1.0", + "@supergrain/test-utils": "workspace:*", + "@vitest/browser": "4.1.5", + "@vitest/browser-playwright": "4.1.5", "playwright": "^1.55.0", - "vitest": "4.1.0" + "vitest": "4.1.5" } } diff --git a/packages/queries/tests/memory/queries.memory.spec.ts b/packages/queries/tests/memory/queries.memory.spec.ts new file mode 100644 index 00000000..f60b2581 --- /dev/null +++ b/packages/queries/tests/memory/queries.memory.spec.ts @@ -0,0 +1,214 @@ +import { createDocumentStore, type DocumentStore } from "@supergrain/silo"; +import { + HAS_GC, + assertGcAvailable, + delay, + expectCollectible, + expectRetainedHeapBudget, +} from "@supergrain/test-utils/memory"; +import { describe, it } from "vitest"; + +import { createQuery, type QueryAdapter } from "../../src"; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +interface PlanbookRef { + type: string; + id: string; + offset: number; +} + +type Models = { + planbooks_for_user: { + id: string; + type: "planbooks_for_user"; + results: Array; + nextOffset: number | null; + }; + planbook: { id: string; type: "planbook"; title?: string }; +}; + +function makeStore(): DocumentStore { + return createDocumentStore({ + models: { + planbooks_for_user: { adapter: { find: () => Promise.resolve({ data: [] }) } }, + planbook: { adapter: { find: () => Promise.resolve({ data: [] }) } }, + }, + }); +} + +function makeRefs(seed: number, width = 16): Array { + return Array.from({ length: width }, (_, index) => ({ + type: "planbook", + id: `pb-${seed}-${index}`, + offset: index, + })); +} + +it("GC is exposed (required for all queries memory tests)", () => { + assertGcAvailable(); +}); + +describe.runIf(HAS_GC)("queries memory", () => { + it("collects a destroyed query and its store after refetch resolves", async () => { + await expectCollectible(async () => { + const store = makeStore(); + const fetched = deferred<{ + data: { results: Array }; + meta: { nextOffset: number | null }; + }>(); + const adapter: QueryAdapter = { + fetch: () => fetched.promise, + }; + const q = createQuery({ store, adapter, type: "planbooks_for_user", id: "u1" }); + void q.refetch(); + return { + targets: [store as object, q as object], + teardown: () => q.destroy(), + settle: async () => { + fetched.resolve({ data: { results: makeRefs(1) }, meta: { nextOffset: null } }); + await delay(); + }, + }; + }); + }); + + // Live-subscription leak surface: createQuery accepts a `subscribe` hook and + // calls the returned unsub on destroy(). If destroy() doesn't release the + // closure, the registered onInvalidate callback (which holds fetchPage, + // which holds the store + adapter) leaks. + it("collects a destroyed query that registered a live subscription", async () => { + await expectCollectible(async () => { + const store = makeStore(); + const adapter: QueryAdapter = { + fetch: () => + Promise.resolve({ + data: { results: makeRefs(2) }, + meta: { nextOffset: null }, + }), + }; + const subscribers = new Set<() => void>(); + const q = createQuery({ + store, + adapter, + type: "planbooks_for_user", + id: "u2", + subscribe: (_type, _id, onInvalidate) => { + subscribers.add(onInvalidate); + return () => subscribers.delete(onInvalidate); + }, + }); + void q.refetch(); + return { + targets: [store as object, q as object], + teardown: () => q.destroy(), + settle: async () => { + await delay(); + }, + }; + }); + }); + + // Targeted listener-accumulation test: many queries against one shared + // subscriber registry. If destroy() ever fails to fire unsub, the registry + // keeps growing and the resulting closures pin the store + adapter. + it("does not accumulate live subscribers across many query lifecycles", async () => { + await expectRetainedHeapBudget(async () => { + const store = makeStore(); + const subscribers = new Set<() => void>(); + const adapter: QueryAdapter = { + fetch: () => + Promise.resolve({ + data: { results: [] as Array }, + meta: { nextOffset: null }, + }), + }; + for (let index = 0; index < 200; index++) { + const q = createQuery({ + store, + adapter, + type: "planbooks_for_user", + id: `u-${index}`, + subscribe: (_type, _id, onInvalidate) => { + subscribers.add(onInvalidate); + return () => subscribers.delete(onInvalidate); + }, + }); + await q.refetch(); + q.destroy(); + } + // After all queries are destroyed, the registry should be empty — any + // residual entries are listener leaks. + if (subscribers.size > 0) { + throw new Error( + `Expected 0 live subscribers after 200 destroyed queries, found ${subscribers.size}`, + ); + } + await delay(); + }, 3_500_000); + }); + + // Destroy mid-fetch: the racy case. Pending fetch resolves AFTER destroy(). + // The destroyed flag must short-circuit the result write so nothing in the + // closure pins the store. + it("collects a query destroyed before its in-flight fetch resolves", async () => { + await expectCollectible(async () => { + const store = makeStore(); + const fetched = deferred<{ + data: { results: Array }; + meta: { nextOffset: number | null }; + }>(); + const adapter: QueryAdapter = { + fetch: () => fetched.promise, + }; + const q = createQuery({ store, adapter, type: "planbooks_for_user", id: "u3" }); + void q.refetch(); + q.destroy(); + return { + targets: [store as object, q as object], + settle: async () => { + fetched.resolve({ data: { results: makeRefs(3) }, meta: { nextOffset: null } }); + await delay(); + }, + }; + }); + }); + + it("retained heap stays bounded across 250 create/refetch/destroy cycles", async () => { + await expectRetainedHeapBudget(async () => { + const store = makeStore(); + for (let index = 0; index < 250; index++) { + const adapter: QueryAdapter = { + fetch: () => + Promise.resolve({ + data: { results: makeRefs(index, 8) }, + meta: { nextOffset: null }, + }), + }; + const q = createQuery({ + store, + adapter, + type: "planbooks_for_user", + id: `pages-${index}`, + }); + await q.refetch(); + q.destroy(); + } + await delay(); + }, 5_000_000); + }); +}); diff --git a/packages/silo/package.json b/packages/silo/package.json index 1ef423e4..5062a373 100644 --- a/packages/silo/package.json +++ b/packages/silo/package.json @@ -76,17 +76,19 @@ "@supergrain/kernel": "workspace:*" }, "devDependencies": { + "@supergrain/husk": "workspace:*", + "@supergrain/test-utils": "workspace:*", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "4.1.0", + "@vitest/browser": "4.1.5", "msw": "^2.13.4", "playwright": "^1.55.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "vitest": "4.1.0" + "vitest": "4.1.5" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0" diff --git a/packages/silo/tests/memory/fixtures.ts b/packages/silo/tests/memory/fixtures.ts new file mode 100644 index 00000000..c508f27c --- /dev/null +++ b/packages/silo/tests/memory/fixtures.ts @@ -0,0 +1,168 @@ +import { delay } from "@supergrain/test-utils/memory"; + +import { createDocumentStore } from "../../src"; + +export interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export interface UserDoc { + id: string; + name: string; + payload: Array; +} + +export interface DashboardParams { + workspaceId: number; + active: boolean; +} + +export interface DashboardResult { + total: number; + ids: Array; + payload: Array; +} + +export type Models = { + user: UserDoc; +}; + +export type Queries = { + dashboard: { + params: DashboardParams; + result: DashboardResult; + }; +}; + +export function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +export function makeUser(id: string, seed: number): UserDoc { + return { + id, + name: `User-${seed}-${id}`, + payload: Array.from({ length: 18 }, (_, index) => seed + index), + }; +} + +export function makeDashboard(params: DashboardParams, seed: number): DashboardResult { + return { + total: params.workspaceId * 10 + seed, + ids: [`${params.workspaceId}-1`, `${params.workspaceId}-2`], + payload: Array.from({ length: 24 }, (_, index) => seed + index), + }; +} + +export interface AsyncDocCall { + ids: Array; + deferred: Deferred>; +} + +export interface AsyncQueryCall { + paramsList: Array; + deferred: Deferred>; +} + +export function createAsyncStore() { + const docCalls: Array = []; + const queryCalls: Array = []; + + const store = createDocumentStore({ + models: { + user: { + adapter: { + async find(ids) { + const run = deferred>(); + docCalls.push({ ids: [...ids], deferred: run }); + return run.promise; + }, + }, + }, + }, + queries: { + dashboard: { + adapter: { + async find(paramsList) { + const run = deferred>(); + queryCalls.push({ paramsList: [...paramsList], deferred: run }); + return run.promise; + }, + }, + }, + }, + batchWindowMs: 1, + batchSize: 3, + }); + + return { store, docCalls, queryCalls }; +} + +export async function flushFinder(): Promise { + await delay(10); + await Promise.resolve(); +} + +export async function settleStoreRound(seed: number): Promise { + const { store, docCalls, queryCalls } = createAsyncStore(); + + const userA = store.find("user", "1"); + const userB = store.find("user", "1"); + const userC = store.find("user", "2"); + const userD = store.find("user", "3"); + + const dashboardA = store.findQuery("dashboard", { workspaceId: seed, active: true }); + const dashboardB = store.findQuery("dashboard", { workspaceId: seed, active: true }); + const dashboardC = store.findQuery("dashboard", { workspaceId: seed + 1, active: true }); + + void userA; + void userB; + void dashboardA; + void dashboardB; + + await flushFinder(); + store.clearMemory(); + + for (const call of [...docCalls].reverse()) { + if (seed % 3 === 0) { + call.deferred.reject(new Error(`doc-failure-${seed}`)); + continue; + } + const docs = + seed % 4 === 0 + ? call.ids + .slice(0, Math.max(1, call.ids.length - 1)) + .map((id, index) => makeUser(id, seed + index)) + : call.ids.map((id, index) => makeUser(id, seed + index)); + call.deferred.resolve(docs); + } + + for (const call of [...queryCalls].reverse()) { + if (seed % 5 === 0) { + call.deferred.reject(new Error(`query-failure-${seed}`)); + continue; + } + call.deferred.resolve( + call.paramsList.map((params, index) => makeDashboard(params, seed + index)), + ); + } + + await Promise.allSettled([ + userC.promise ?? Promise.resolve(undefined), + userD.promise ?? Promise.resolve(undefined), + dashboardC.promise ?? Promise.resolve(undefined), + ...docCalls.map((call) => call.deferred.promise), + ...queryCalls.map((call) => call.deferred.promise), + ]); + + store.clearMemory(); + await delay(); +} diff --git a/packages/silo/tests/memory/silo.memory.soak.spec.ts b/packages/silo/tests/memory/silo.memory.soak.spec.ts new file mode 100644 index 00000000..d39bc1f2 --- /dev/null +++ b/packages/silo/tests/memory/silo.memory.soak.spec.ts @@ -0,0 +1,29 @@ +import { + HAS_GC, + assertGcAvailable, + collectHeapSamples, + expectTrendToFlatten, +} from "@supergrain/test-utils/memory"; +import { describe, it } from "vitest"; + +import { settleStoreRound } from "./fixtures"; + +it("GC is exposed (required for silo soak)", () => { + assertGcAvailable(); +}); + +describe.runIf(HAS_GC)("silo memory soak", () => { + it("stays flat during extended async finder churn", async () => { + const samples = await collectHeapSamples(10, async (round) => { + for (let index = 0; index < 60; index++) { + await settleStoreRound(round * 10_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 6_000_000, + maxLastDeltaBytes: 900_000, + maxTailHeadRatio: 2.0, + }); + }); +}); diff --git a/packages/silo/tests/memory/silo.memory.spec.ts b/packages/silo/tests/memory/silo.memory.spec.ts new file mode 100644 index 00000000..6985eddf --- /dev/null +++ b/packages/silo/tests/memory/silo.memory.spec.ts @@ -0,0 +1,126 @@ +import { + HAS_GC, + assertGcAvailable, + collectHeapSamples, + delay, + expectCollectible, + expectRetainedHeapBudget, + expectTrendToFlatten, +} from "@supergrain/test-utils/memory"; +import { describe, it } from "vitest"; + +import { + createAsyncStore, + flushFinder, + makeDashboard, + makeUser, + settleStoreRound, +} from "./fixtures"; + +it("GC is exposed (required for all silo memory tests)", () => { + assertGcAvailable(); +}); + +describe.runIf(HAS_GC)("silo memory", () => { + it("collects stores and handles after clearMemory and dropping the store", async () => { + await expectCollectible(async () => { + const { store, docCalls, queryCalls } = createAsyncStore(); + const userHandle = store.find("user", "1"); + const queryHandle = store.findQuery("dashboard", { workspaceId: 7, active: true }); + + await flushFinder(); + + docCalls[0]!.deferred.resolve([makeUser("1", 7)]); + queryCalls[0]!.deferred.resolve([makeDashboard({ workspaceId: 7, active: true }, 7)]); + + await Promise.allSettled([ + userHandle.promise ?? Promise.resolve(undefined), + queryHandle.promise ?? Promise.resolve(undefined), + ...docCalls.map((call) => call.deferred.promise), + ...queryCalls.map((call) => call.deferred.promise), + ]); + + store.clearMemory(); + + return { + targets: [store as object, userHandle as object, queryHandle as object], + settle: () => delay(), + }; + }); + }); + + it("keeps retained heap bounded across repeated batching, clearMemory, and async settlement", async () => { + await expectRetainedHeapBudget(async () => { + for (let index = 0; index < 80; index++) { + await settleStoreRound(index); + } + }, 4_000_000); + }); + + // High-N retention test against fresh stores per round. If the per-round + // create + use + clearMemory + drop cycle leaks any references at a linear + // rate this test will exceed budget; bounded retention proves the store + // tear-down path actually releases. + it("retained heap stays sublinear across 400 settle rounds", async () => { + await expectRetainedHeapBudget(async () => { + for (let index = 0; index < 400; index++) { + await settleStoreRound(index); + } + }, 7_000_000); + }); + + it("flattens retained heap across repeated async store rounds", async () => { + const samples = await collectHeapSamples(8, async (round) => { + for (let index = 0; index < 40; index++) { + await settleStoreRound(round * 1_000 + index); + } + }); + + expectTrendToFlatten(samples, { + maxGrowthBytes: 4_000_000, + maxLastDeltaBytes: 700_000, + maxTailHeadRatio: 1.8, + }); + }); + + it("persistent store stays heap-bounded with unique query-key accumulation", async () => { + // Unlike settleStoreRound (which creates a new store per round), this test + // uses a single long-lived store and exercises the accumulation pattern + // that a real app would see. + await expectRetainedHeapBudget(async () => { + const { store, docCalls, queryCalls } = createAsyncStore(); + + for (let round = 0; round < 40; round++) { + // Each round uses a new workspaceId — new query keys accumulate in the bucket + store.findQuery("dashboard", { workspaceId: round, active: true }); + store.find("user", String(round)); + + await flushFinder(); + + for (const call of docCalls.splice(0)) { + if (round % 3 === 0) { + call.deferred.reject(new Error("round-error")); + } else { + call.deferred.resolve(call.ids.map((id, i) => makeUser(id, round + i))); + } + } + for (const call of queryCalls.splice(0)) { + if (round % 5 === 0) { + call.deferred.reject(new Error("query-error")); + } else { + call.deferred.resolve(call.paramsList.map((p, i) => makeDashboard(p, round + i))); + } + } + await delay(5); + + if (round % 10 === 9) { + store.clearMemory(); + } + } + + // Final cleanup + store.clearMemory(); + await delay(10); + }, 4_500_000); + }); +}); diff --git a/packages/silo/tests/react/realistic-app.memory.spec.tsx b/packages/silo/tests/react/realistic-app.memory.spec.tsx new file mode 100644 index 00000000..b4b47807 --- /dev/null +++ b/packages/silo/tests/react/realistic-app.memory.spec.tsx @@ -0,0 +1,195 @@ +import { useResource } from "@supergrain/husk/react"; +import { tracked } from "@supergrain/kernel/react"; +import { collectBrowserSamples, expectBrowserTrend } from "@supergrain/test-utils/browser-memory"; +import { act, cleanup, render, within } from "@testing-library/react"; +import React, { useState } from "react"; +import { afterEach, describe, it } from "vitest"; + +import { type DocumentStore, type DocumentStoreConfig } from "../../src"; +import { createDocumentStoreContext } from "../../src/react"; + +afterEach(() => cleanup()); + +// Realistic app shape: Provider mounts once per session, list view loads via +// useQuery, clicking an item mounts a detail view that combines useDocument +// with useResource for derived/enriched data. Closing the detail unmounts it. +// Pagination flips the query params. This is the integrated lifecycle that +// matters for production confidence — it exercises Provider, query, document, +// resource, conditional component mount/unmount, prop changes, and full +// teardown all under StrictMode double-invocation. + +interface Item { + id: string; + name: string; + payload: Array; +} +interface ListParams { + page: number; +} +type Models = { item: Item }; +type Queries = { itemList: { params: ListParams; result: Array } }; + +const { Provider, useDocument, useQuery } = + createDocumentStoreContext>(); + +function makeItem(page: number, idx: number): Item { + return { + id: `item-${page}-${idx}`, + name: `Item ${page}.${idx}`, + payload: Array.from({ length: 12 }, (_, i) => page + idx + i), + }; +} + +function makeStoreConfig(seed: number): DocumentStoreConfig { + return { + models: { + item: { + adapter: { + async find(ids) { + return ids.map((id) => { + const m = id.match(/^item-(\d+)-(\d+)$/); + return m ? makeItem(Number(m[1]), Number(m[2])) : makeItem(seed, 0); + }); + }, + }, + }, + }, + queries: { + itemList: { + adapter: { + async find(paramsList) { + return paramsList.map((p) => Array.from({ length: 6 }, (_, i) => makeItem(p.page, i))); + }, + }, + }, + }, + batchWindowMs: 1, + }; +} + +const ItemDetail = tracked(function ItemDetail({ id }: { id: string }) { + const handle = useDocument("item", id); + // Derived async work tied to the doc: a husk resource that recomputes when + // the doc data changes. Real apps frequently combine doc subscriptions with + // local async work (formatting, derivations, side fetches). + const enriched = useResource( + { summary: null as string | null }, + async (state, { abortSignal }) => { + const data = handle.data; + if (!data) return; + await Promise.resolve(); + if (abortSignal.aborted) return; + state.summary = `${data.name} (sum=${data.payload.reduce((a, b) => a + b, 0)})`; + }, + ); + const [expanded, setExpanded] = useState(false); + return ( +
+ + {enriched.summary ?? handle.data?.name ?? "..."} + {expanded && {handle.data?.payload.join(",")}} +
+ ); +}); + +const App = tracked(function App({ initialPage }: { initialPage: number }) { + const [page, setPage] = useState(initialPage); + const [selectedId, setSelectedId] = useState(null); + const list = useQuery("itemList", { page }); + return ( +
+ + + {(list.data ?? []).map((item) => ( + + ))} + {selectedId && } +
+ ); +}); + +async function flushAsync(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); +} + +describe("realistic app workflow memory", () => { + // 5 rounds; each round is one full app session: mount → 60 user actions + // (item selects, expand toggles, closes, paginations) → unmount. After + // unmount the entire Provider + store + queries + documents + resources + + // their effects must be releasable. If any path retains references across + // the unmount boundary, retained heap climbs across rounds and trips the + // budget or tail/head ratio. + it("keeps Chromium heap flat across simulated user sessions", async () => { + const samples = await collectBrowserSamples(5, async (round) => { + const view = render( + + + + + , + ); + // Wait for initial query to resolve. + await flushAsync(); + + let currentPage = round * 100; + for (let action = 0; action < 60; action++) { + const itemIdx = action % 6; + const itemId = `item-${currentPage}-${itemIdx}`; + const select = within(view.container).queryByTestId(`select-${itemId}`); + if (select) { + await act(async () => { + select.click(); + }); + // Let detail mount, useDocument fetch, useResource resolve. + await flushAsync(); + // Toggle expand on the detail (local state churn while subscribed). + const expand = within(view.container).queryByTestId("toggle-expand"); + if (expand) { + await act(async () => { + expand.click(); + }); + await act(async () => { + expand.click(); + }); + } + await act(async () => { + within(view.container).getByTestId("close-detail").click(); + }); + } + if (action % 6 === 5) { + // Paginate — query params change, previous query subscription teardown. + await act(async () => { + within(view.container).getByTestId("next-page").click(); + }); + currentPage++; + await flushAsync(); + } + } + + view.unmount(); + cleanup(); + }); + + expectBrowserTrend(samples, { + // ~300 detail mount/unmount + ~50 query param changes + StrictMode 2x + // per session × 5 sessions. Healthy retention plateaus well under this. + maxGrowthBytes: 6_000_000, + maxLastDeltaBytes: 1_500_000, + maxTailHeadRatio: 1.8, + }); + }); +}); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 00000000..24f26ab9 --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,27 @@ +{ + "name": "@supergrain/test-utils", + "version": "0.0.0", + "private": true, + "description": "Internal test helpers for Supergrain (memory, browser).", + "license": "MIT", + "type": "module", + "exports": { + "./memory": { + "@supergrain/source": "./src/memory.ts", + "types": "./src/memory.ts" + }, + "./browser-memory": { + "@supergrain/source": "./src/browser-memory.ts", + "types": "./src/browser-memory.ts" + } + }, + "scripts": { + "lint": "oxlint -c ../../.oxlintrc.json src/", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.18.3", + "typescript": "^5.9.2", + "vitest": "4.1.5" + } +} diff --git a/packages/test-utils/src/browser-memory.ts b/packages/test-utils/src/browser-memory.ts new file mode 100644 index 00000000..6f5f2bb2 --- /dev/null +++ b/packages/test-utils/src/browser-memory.ts @@ -0,0 +1,80 @@ +import { expect } from "vitest"; +import { cdp } from "vitest/browser"; + +export async function forceBrowserGc(cycles = 4): Promise { + const runtime = globalThis as typeof globalThis & { gc?: () => void }; + if (typeof runtime.gc !== "function") { + throw new TypeError("Browser memory tests require Chromium to expose gc()."); + } + for (let index = 0; index < cycles; index++) { + runtime.gc(); + await Promise.resolve(); + } +} + +interface CdpSession { + send: (method: string) => Promise; +} + +let performanceEnabled = false; + +export async function browserHeapUsed(): Promise { + const session = cdp() as CdpSession; + if (!performanceEnabled) { + await session.send("Performance.enable"); + performanceEnabled = true; + } + const result = (await session.send("Performance.getMetrics")) as { + metrics: Array<{ name: string; value: number }>; + }; + const heap = result.metrics.find((metric) => metric.name === "JSHeapUsedSize"); + if (!heap) { + throw new Error("Unable to read JSHeapUsedSize from Chromium metrics."); + } + return heap.value; +} + +export async function collectBrowserSamples( + rounds: number, + runRound: (round: number) => void | Promise, +): Promise> { + const samples: Array = []; + for (let round = 0; round < rounds; round++) { + await runRound(round); + await forceBrowserGc(); + samples.push(await browserHeapUsed()); + } + return samples; +} + +export interface BrowserTrendOptions { + maxGrowthBytes: number; + maxLastDeltaBytes: number; + /** Tail-to-head ratio check (mean of last 2 vs first 2). Requires ≥ 4 samples. */ + maxTailHeadRatio?: number; +} + +export function expectBrowserTrend( + samples: ReadonlyArray, + options: BrowserTrendOptions, +): void { + expect(samples.length).toBeGreaterThanOrEqual(2); + const totalGrowth = samples.at(-1)! - samples[0]!; + const deltas = samples.slice(1).map((sample, index) => sample - samples[index]!); + expect(totalGrowth, "total heap growth exceeded budget").toBeLessThanOrEqual( + options.maxGrowthBytes, + ); + expect(deltas.at(-1) ?? 0, "last-round heap delta exceeded budget").toBeLessThanOrEqual( + options.maxLastDeltaBytes, + ); + if (options.maxTailHeadRatio !== undefined && samples.length >= 4) { + const headAvg = (samples[0]! + samples[1]!) / 2; + const tailAvg = (samples.at(-1)! + samples.at(-2)!) / 2; + if (headAvg > 0) { + expect( + tailAvg / headAvg, + "tail-to-head heap ratio indicates sustained monotonic growth", + ).toBeLessThanOrEqual(options.maxTailHeadRatio); + } + } +} diff --git a/packages/test-utils/src/memory.ts b/packages/test-utils/src/memory.ts new file mode 100644 index 00000000..3e768994 --- /dev/null +++ b/packages/test-utils/src/memory.ts @@ -0,0 +1,206 @@ +import { expect } from "vitest"; + +const runtime = globalThis as typeof globalThis & { + gc?: () => void; + process?: { + memoryUsage?: () => { heapUsed: number }; + }; +}; + +// `--expose-gc` always surfaces gc as `globalThis.gc` in Node and via +// `--js-flags=--expose-gc` in Chromium, so we can read it directly without +// an eval-based fallback. +export const HAS_GC = + typeof runtime.gc === "function" && typeof runtime.process?.memoryUsage === "function"; + +/** + * Always-run sentinel for memory configs. When `pnpm test:memory:node` is used + * this catches mis-configuration that would otherwise silently skip every + * leak check (every `describe.runIf(HAS_GC)` block evaluates to false). + */ +export function assertGcAvailable(): void { + if (!HAS_GC) { + throw new Error( + "Memory tests require GC exposure. Run via: pnpm test:memory:node " + + "(which passes --expose-gc to the worker process). " + + "Without it every memory test skips and no leak is caught.", + ); + } +} + +export function requireGc(): void { + if (typeof runtime.gc !== "function") { + throw new TypeError("Memory tests require node --expose-gc."); + } + if (typeof runtime.process?.memoryUsage !== "function") { + throw new TypeError("Memory tests require process.memoryUsage()."); + } +} + +export async function delay(ms = 0): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function forceGc(cycles = 6): Promise { + requireGc(); + const gc = runtime.gc!; + for (let index = 0; index < cycles; index++) { + gc(); + await Promise.resolve(); + await delay(); + } +} + +export async function heapUsedAfterGc(): Promise { + await forceGc(); + return runtime.process!.memoryUsage!().heapUsed; +} + +export async function expectRetainedHeapBudget( + run: () => void | Promise, + maxGrowthBytes: number, +): Promise { + const before = await heapUsedAfterGc(); + await run(); + const after = await heapUsedAfterGc(); + expect(after - before).toBeLessThanOrEqual(maxGrowthBytes); +} + +export async function collectHeapSamples( + rounds: number, + runRound: (round: number) => void | Promise, +): Promise> { + const samples: Array = []; + for (let round = 0; round < rounds; round++) { + await runRound(round); + samples.push(await heapUsedAfterGc()); + } + return samples; +} + +export interface TrendOptions { + /** Total heap growth from first to last sample. */ + maxGrowthBytes: number; + /** Heap delta of the final round. Catches a leak still in progress. */ + maxLastDeltaBytes: number; + /** + * Maximum number of rounds with positive heap growth. Optional because V8 + * frequently shows small positive deltas across most rounds even with no + * leak (JIT, lazy weak-map cleanup, etc.). When absolute and structural + * signals are present this metric just adds flakiness. + */ + maxPositiveDeltas?: number; + /** + * Maximum ratio of (mean of last two samples) to (mean of first two samples). + * Catches slow-but-steady growth that stays under maxGrowthBytes. Requires at + * least 4 samples. + */ + maxTailHeadRatio?: number; + /** + * Maximum number of consecutive rounds with positive heap growth. Catches an + * unbroken upward slope even when individual deltas are small. + */ + maxConsecutiveGrowthRounds?: number; +} + +export function expectTrendToFlatten(samples: ReadonlyArray, options: TrendOptions): void { + expect(samples.length).toBeGreaterThanOrEqual(2); + const deltas = samples.slice(1).map((sample, index) => sample - samples[index]!); + const totalGrowth = samples.at(-1)! - samples[0]!; + const lastDelta = deltas.at(-1) ?? 0; + + expect(totalGrowth, "total heap growth exceeded budget").toBeLessThanOrEqual( + options.maxGrowthBytes, + ); + expect(lastDelta, "last-round heap delta exceeded budget").toBeLessThanOrEqual( + options.maxLastDeltaBytes, + ); + + if (options.maxPositiveDeltas !== undefined) { + const positiveDeltas = deltas.filter((delta) => delta > 0).length; + expect(positiveDeltas, "too many rounds with positive heap growth").toBeLessThanOrEqual( + options.maxPositiveDeltas, + ); + } + + if (options.maxTailHeadRatio !== undefined && samples.length >= 4) { + const headAvg = (samples[0]! + samples[1]!) / 2; + const tailAvg = (samples.at(-1)! + samples.at(-2)!) / 2; + if (headAvg > 0) { + expect( + tailAvg / headAvg, + "tail-to-head heap ratio indicates sustained monotonic growth", + ).toBeLessThanOrEqual(options.maxTailHeadRatio); + } + } + + if (options.maxConsecutiveGrowthRounds !== undefined) { + let consecutive = 0; + let maxConsecutive = 0; + for (const delta of deltas) { + if (delta > 0) { + consecutive++; + if (consecutive > maxConsecutive) maxConsecutive = consecutive; + } else { + consecutive = 0; + } + } + expect( + maxConsecutive, + "consecutive rounds of heap growth indicate a sustained leak", + ).toBeLessThanOrEqual(options.maxConsecutiveGrowthRounds); + } +} + +export async function expectCollectible( + factory: () => + | { + targets: Array; + teardown?: () => void | Promise; + settle?: () => void | Promise; + } + | Promise<{ + targets: Array; + teardown?: () => void | Promise; + settle?: () => void | Promise; + }>, +): Promise { + let finalized = 0; + const registry = new FinalizationRegistry(() => { + finalized++; + }); + + // V8 retains every variable ever live in an async function across every + // await suspension point. If targets/teardown/settle were held in the same + // frame as the GC polling loop, they would still be reachable from the + // suspended frame and could not be collected. The nested IIFE gives them + // their own frame that is fully released before the loop runs. + const refs = await (async () => { + const { targets, teardown, settle } = await factory(); + const weakRefs = targets.map((target, index) => { + registry.register(target, index); + return new WeakRef(target); + }); + await teardown?.(); + await settle?.(); + return weakRefs; + })(); + + for (let attempt = 0; attempt < 60; attempt++) { + await forceGc(); + if (finalized >= refs.length || refs.every((ref) => ref.deref() === undefined)) { + break; + } + await delay(10); + } + + const survivingIndices = refs + .map((ref, index) => ({ index, alive: ref.deref() !== undefined })) + .filter((entry) => entry.alive) + .map((entry) => entry.index); + + expect( + survivingIndices, + `Targets at indices [${survivingIndices.join(", ")}] were not garbage collected after 60 GC attempts`, + ).toEqual([]); +} diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 00000000..f5126a65 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "target": "ES2022", + "types": ["node", "vitest/browser"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "rootDir": "." + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1335d6f6..85f198b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,11 +28,11 @@ importers: specifier: ^19.1.9 version: 19.1.9(@types/react@19.1.13) '@vitest/browser': - specifier: ^4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: ^4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) '@vitest/browser-playwright': - specifier: ^4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: ^4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -70,8 +70,8 @@ importers: specifier: ^1.6.4 version: 1.6.4(@algolia/client-search@5.40.0)(@types/node@22.18.3)(@types/react@19.1.13)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.2) vitest: - specifier: ^4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: ^4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/comparisons: dependencies: @@ -104,11 +104,11 @@ importers: version: 5.0.8(@types/react@19.1.13)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)) devDependencies: '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/doc-tests: dependencies: @@ -141,8 +141,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(vite@7.1.5(@types/node@22.18.3)) '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) playwright: specifier: ^1.55.0 version: 1.55.0 @@ -159,8 +159,8 @@ importers: specifier: ^7.1.5 version: 7.1.5(@types/node@22.18.3) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/husk: dependencies: @@ -168,6 +168,9 @@ importers: specifier: workspace:* version: link:../kernel devDependencies: + '@supergrain/test-utils': + specifier: workspace:* + version: link:../test-utils '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -184,8 +187,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(vite@7.1.5(@types/node@22.18.3)) '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) playwright: specifier: ^1.55.0 version: 1.55.0 @@ -202,8 +205,8 @@ importers: specifier: ^7.1.5 version: 7.1.5(@types/node@22.18.3) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/js-krauset: dependencies: @@ -236,8 +239,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(vite@7.1.5(@types/node@22.18.3)) '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) playwright: specifier: ^1.55.0 version: 1.55.0 @@ -254,8 +257,8 @@ importers: specifier: ^4.5.4 version: 4.5.4(@types/node@22.18.3)(rollup@4.50.1)(typescript@5.9.2)(vite@7.1.5(@types/node@22.18.3)) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/js-krauset-main: dependencies: @@ -291,8 +294,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(vite@7.1.5(@types/node@22.18.3)) '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) playwright: specifier: ^1.55.0 version: 1.55.0 @@ -309,8 +312,8 @@ importers: specifier: ^4.5.4 version: 4.5.4(@types/node@22.18.3)(rollup@4.50.1)(typescript@5.9.2)(vite@7.1.5(@types/node@22.18.3)) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/js-krauset-react-hooks: dependencies: @@ -343,8 +346,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(vite@7.1.5(@types/node@22.18.3)) '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) playwright: specifier: ^1.55.0 version: 1.55.0 @@ -361,8 +364,8 @@ importers: specifier: ^4.5.4 version: 4.5.4(@types/node@22.18.3)(rollup@4.50.1)(typescript@5.9.2)(vite@7.1.5(@types/node@22.18.3)) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/kernel: dependencies: @@ -382,6 +385,9 @@ importers: '@supergrain/mill': specifier: workspace:* version: link:../mill + '@supergrain/test-utils': + specifier: workspace:* + version: link:../test-utils '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -401,8 +407,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(vite@7.1.5(@types/node@22.18.3)) '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) arktype: specifier: ^2.2.0 version: 2.2.0 @@ -437,8 +443,8 @@ importers: specifier: ^7.1.5 version: 7.1.5(@types/node@22.18.3) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) zustand: specifier: ^5.0.8 version: 5.0.8(@types/react@19.1.13)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)) @@ -453,8 +459,8 @@ importers: version: 2.0.7 devDependencies: vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/queries: dependencies: @@ -465,18 +471,21 @@ importers: specifier: workspace:* version: link:../silo devDependencies: + '@supergrain/test-utils': + specifier: workspace:* + version: link:../test-utils '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) '@vitest/browser-playwright': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) playwright: specifier: ^1.55.0 version: 1.55.0 vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages/silo: dependencies: @@ -484,6 +493,12 @@ importers: specifier: workspace:* version: link:../kernel devDependencies: + '@supergrain/husk': + specifier: workspace:* + version: link:../husk + '@supergrain/test-utils': + specifier: workspace:* + version: link:../test-utils '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -500,8 +515,8 @@ importers: specifier: ^4.7.0 version: 4.7.0(vite@7.1.5(@types/node@22.18.3)) '@vitest/browser': - specifier: 4.1.0 - version: 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + specifier: 4.1.5 + version: 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) msw: specifier: ^2.13.4 version: 2.13.4(@types/node@22.18.3)(typescript@5.9.2) @@ -515,8 +530,20 @@ importers: specifier: ^19.1.1 version: 19.2.4(react@19.2.4) vitest: - specifier: 4.1.0 - version: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + + packages/test-utils: + devDependencies: + '@types/node': + specifier: ^22.18.3 + version: 22.18.3 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: 4.1.5 + version: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) packages: @@ -1876,45 +1903,45 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 - '@vitest/browser-playwright@4.1.0': - resolution: {integrity: sha512-2RU7pZELY9/aVMLmABNy1HeZ4FX23FXGY1jRuHLHgWa2zaAE49aNW2GLzebW+BmbTZIKKyFF1QXvk7DEWViUCQ==} + '@vitest/browser-playwright@4.1.5': + resolution: {integrity: sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==} peerDependencies: playwright: '*' - vitest: 4.1.0 + vitest: 4.1.5 - '@vitest/browser@4.1.0': - resolution: {integrity: sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ==} + '@vitest/browser@4.1.5': + resolution: {integrity: sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==} peerDependencies: - vitest: 4.1.0 + vitest: 4.1.5 - '@vitest/expect@4.1.0': - resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} - '@vitest/mocker@4.1.0': - resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.1.0': - resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - '@vitest/runner@4.1.0': - resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} - '@vitest/snapshot@4.1.0': - resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} - '@vitest/spy@4.1.0': - resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} - '@vitest/utils@4.1.0': - resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} '@volar/language-core@2.4.23': resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} @@ -3374,21 +3401,23 @@ packages: postcss: optional: true - vitest@4.1.0: - resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.0 - '@vitest/browser-preview': 4.1.0 - '@vitest/browser-webdriverio': 4.1.0 - '@vitest/ui': 4.1.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -3402,6 +3431,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -4757,29 +4790,29 @@ snapshots: vite: 5.4.20(@types/node@22.18.3) vue: 3.5.22(typescript@5.9.2) - '@vitest/browser-playwright@4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0)': + '@vitest/browser-playwright@4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5)': dependencies: - '@vitest/browser': 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) - '@vitest/mocker': 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + '@vitest/browser': 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) + '@vitest/mocker': 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) playwright: 1.55.0 tinyrainbow: 3.1.0 - vitest: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + vitest: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0)': + '@vitest/browser@4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) - '@vitest/utils': 4.1.0 + '@vitest/mocker': 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + '@vitest/utils': 4.1.5 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + vitest: 4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -4787,45 +4820,45 @@ snapshots: - utf-8-validate - vite - '@vitest/expect@4.1.0': + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))': + '@vitest/mocker@4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3))': dependencies: - '@vitest/spy': 4.1.0 + '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.13.4(@types/node@22.18.3)(typescript@5.9.2) vite: 7.1.5(@types/node@22.18.3) - '@vitest/pretty-format@4.1.0': + '@vitest/pretty-format@4.1.5': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.0': + '@vitest/runner@4.1.5': dependencies: - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.5 pathe: 2.0.3 - '@vitest/snapshot@4.1.0': + '@vitest/snapshot@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.0': {} + '@vitest/spy@4.1.5': {} - '@vitest/utils@4.1.0': + '@vitest/utils@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.0 + '@vitest/pretty-format': 4.1.5 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -6283,15 +6316,15 @@ snapshots: - typescript - universal-cookie - vitest@4.1.0(@types/node@22.18.3)(@vitest/browser-playwright@4.1.0)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)): + vitest@4.1.5(@types/node@22.18.3)(@vitest/browser-playwright@4.1.5)(jsdom@26.1.0)(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)): dependencies: - '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) - '@vitest/pretty-format': 4.1.0 - '@vitest/runner': 4.1.0 - '@vitest/snapshot': 4.1.0 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(vite@7.1.5(@types/node@22.18.3)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -6307,7 +6340,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.18.3 - '@vitest/browser-playwright': 4.1.0(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.0) + '@vitest/browser-playwright': 4.1.5(msw@2.13.4(@types/node@22.18.3)(typescript@5.9.2))(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.3))(vitest@4.1.5) jsdom: 26.1.0 transitivePeerDependencies: - msw diff --git a/vitest.memory.browser.config.ts b/vitest.memory.browser.config.ts new file mode 100644 index 00000000..8fe00c94 --- /dev/null +++ b/vitest.memory.browser.config.ts @@ -0,0 +1,71 @@ +import react from "@vitejs/plugin-react"; +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; + +const conditions = ["@supergrain/source"]; +const resolve = { conditions }; +const ssr = { resolve: { conditions } }; + +const chromiumProvider = playwright({ + launchOptions: { + args: ["--js-flags=--expose-gc", "--enable-precise-memory-info"], + }, +}); + +export default defineConfig({ + test: { + fileParallelism: false, + maxWorkers: 1, + minWorkers: 1, + projects: [ + { + plugins: [react()], + test: { + include: ["packages/kernel/tests/react/**/*.memory.spec.tsx"], + browser: { + enabled: true, + provider: chromiumProvider, + headless: true, + instances: [{ browser: "chromium" }], + }, + setupFiles: ["./packages/kernel/tests/react/setup.ts"], + globals: true, + }, + resolve, + ssr, + }, + { + plugins: [react()], + test: { + include: ["packages/husk/tests/react/**/*.memory.spec.tsx"], + browser: { + enabled: true, + provider: chromiumProvider, + headless: true, + instances: [{ browser: "chromium" }], + }, + setupFiles: ["./packages/husk/tests/react/setup.ts"], + globals: true, + }, + resolve, + ssr, + }, + { + plugins: [react()], + test: { + include: ["packages/silo/tests/react/**/*.memory.spec.tsx"], + browser: { + enabled: true, + provider: chromiumProvider, + headless: true, + instances: [{ browser: "chromium" }], + }, + setupFiles: ["./packages/silo/tests/react/setup.ts"], + globals: true, + }, + resolve, + ssr, + }, + ], + }, +}); diff --git a/vitest.memory.node.config.ts b/vitest.memory.node.config.ts new file mode 100644 index 00000000..fa4eeb83 --- /dev/null +++ b/vitest.memory.node.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from "vitest/config"; + +// @supergrain/source condition makes each package resolve to its TypeScript +// source files instead of the built dist artefacts. +const conditions = ["@supergrain/source"]; + +export default defineConfig({ + resolve: { conditions }, + ssr: { resolve: { conditions } }, + test: { + pool: "forks", + // --expose-gc is required so `globalThis.gc()` is available in test + // workers. Vitest 4 reads `project.config.execArgv` (not the root + // test.execArgv) when spawning forked workers. Using a flat config + // (no `projects` array) makes this the single project config, so + // execArgv flows through correctly. The sentinel test in every suite + // fails loudly if gc() is absent so mis-configuration is never silent. + execArgv: ["--expose-gc"], + include: [ + "packages/kernel/tests/memory/**/*.memory.spec.ts", + "packages/husk/tests/memory/**/*.memory.spec.ts", + "packages/silo/tests/memory/**/*.memory.spec.ts", + "packages/queries/tests/memory/**/*.memory.spec.ts", + ], + // Soak tests live in *.memory.soak.spec.ts and have their own config. + exclude: ["**/*.memory.soak.spec.ts"], + environment: "node", + fileParallelism: false, + maxWorkers: 1, + minWorkers: 1, + // Extended timeout: each memory suite runs many GC cycles (forceGc runs 6 + // gc() calls with microtask yields between them) multiplied by the number + // of heap-sample rounds. 30 s gives comfortable headroom on slow CI agents. + testTimeout: 30_000, + }, +}); diff --git a/vitest.memory.soak.config.ts b/vitest.memory.soak.config.ts new file mode 100644 index 00000000..a15d9067 --- /dev/null +++ b/vitest.memory.soak.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vitest/config"; + +// @supergrain/source condition makes each package resolve to its TypeScript +// source files instead of the built dist artefacts. +const conditions = ["@supergrain/source"]; + +export default defineConfig({ + resolve: { conditions }, + ssr: { resolve: { conditions } }, + test: { + pool: "forks", + execArgv: ["--expose-gc"], + include: [ + "packages/kernel/tests/memory/**/*.memory.soak.spec.ts", + "packages/husk/tests/memory/**/*.memory.soak.spec.ts", + "packages/silo/tests/memory/**/*.memory.soak.spec.ts", + ], + environment: "node", + fileParallelism: false, + maxWorkers: 1, + minWorkers: 1, + // Soak runs many more cycles per round; allow more wall-clock per file. + testTimeout: 60_000, + }, +});