From 1846032c5158120493571fefe8cc866a631c22bf Mon Sep 17 00:00:00 2001 From: Dmytro Zelenetskyi Date: Mon, 3 Aug 2026 21:16:09 +0200 Subject: [PATCH] Add fuzz testing --- packages/modals/package.json | 1 + packages/modals/src/store.property.test.ts | 256 ++++++++++++++++++ packages/slottable/package.json | 1 + .../slottable/src/use-slot.property.test.tsx | 197 ++++++++++++++ pnpm-lock.yaml | 22 ++ pnpm-workspace.yaml | 1 + 6 files changed, 478 insertions(+) create mode 100644 packages/modals/src/store.property.test.ts create mode 100644 packages/slottable/src/use-slot.property.test.tsx diff --git a/packages/modals/package.json b/packages/modals/package.json index e6c2887..ef119a0 100644 --- a/packages/modals/package.json +++ b/packages/modals/package.json @@ -55,6 +55,7 @@ "@vitejs/plugin-react": "6.0.5", "@vitest/coverage-v8": "catalog:", "@zemd/tsconfig": "catalog:", + "fast-check": "catalog:", "happy-dom": "20.11.1", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/modals/src/store.property.test.ts b/packages/modals/src/store.property.test.ts new file mode 100644 index 0000000..9df16b7 --- /dev/null +++ b/packages/modals/src/store.property.test.ts @@ -0,0 +1,256 @@ +import * as fc from "fast-check"; +import { describe, expect, test, vi } from "vitest"; +import { createStore } from "./store"; +import type { UUID } from "./types"; + +const Noop = () => null; +const UNKNOWN_ID = "00000000-0000-0000-0000-000000000000" as UUID; + +type FuzzProps = { + count: number; + enabled: boolean; + label: string; +}; + +type Operation = + | { + type: "append"; + props: FuzzProps; + throwOnClose: boolean; + throwOnOpen: boolean; + } + | { type: "remove"; target: number } + | { type: "removeLatest" } + | { type: "removeAll" }; + +type ModelEntry = { + appendIndex: number; + id: UUID; + props: FuzzProps; + throwOnClose: boolean; +}; + +type CallbackEvent = { + appendIndex: number; + props: Record; +}; + +const propsArbitrary: fc.Arbitrary = fc.record({ + count: fc.integer(), + enabled: fc.boolean(), + label: fc.string(), +}); + +const operationArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ + type: fc.constant<"append">("append"), + props: propsArbitrary, + throwOnClose: fc.boolean(), + throwOnOpen: fc.boolean(), + }), + fc.record({ + type: fc.constant<"remove">("remove"), + target: fc.nat({ max: 59 }), + }), + fc.record({ type: fc.constant<"removeLatest">("removeLatest") }), + fc.record({ type: fc.constant<"removeAll">("removeAll") }), +); + +describe("createStore fuzzing", () => { + test("matches a stack model across arbitrary operation sequences", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + fc.assert( + fc.property( + fc.array(operationArbitrary, { minLength: 1, maxLength: 60 }), + fc.nat({ max: 60 }), + (operations, unsubscribeAt) => { + consoleError.mockClear(); + + const store = createStore(); + const serverSnapshot = store.getServerSnapshot(); + const model: ModelEntry[] = []; + const idsByAppendIndex = new Map(); + const opened: CallbackEvent[] = []; + const closed: CallbackEvent[] = []; + const expectedOpened: CallbackEvent[] = []; + const expectedClosed: CallbackEvent[] = []; + let expectedErrors = 0; + let expectedNotifications = 0; + let notifications = 0; + let subscribed = true; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + + operations.forEach((operation, operationIndex) => { + if (operationIndex === unsubscribeAt) { + unsubscribe(); + subscribed = false; + } + + const previousSnapshot = store.getSnapshot(); + let emitted = false; + + switch (operation.type) { + case "append": { + const id = store.append({ + component: Noop, + props: operation.props, + callbacks: { + onOpen: (props) => { + opened.push({ appendIndex: operationIndex, props }); + if (operation.throwOnOpen) { + throw new Error("fuzzed onOpen failure"); + } + }, + onClose: (props) => { + closed.push({ appendIndex: operationIndex, props }); + if (operation.throwOnClose) { + throw new Error("fuzzed onClose failure"); + } + }, + }, + }); + + idsByAppendIndex.set(operationIndex, id); + model.push({ + appendIndex: operationIndex, + id, + props: operation.props, + throwOnClose: operation.throwOnClose, + }); + expectedOpened.push({ appendIndex: operationIndex, props: operation.props }); + expectedErrors += Number(operation.throwOnOpen); + emitted = true; + break; + } + case "remove": { + const id = idsByAppendIndex.get(operation.target) ?? UNKNOWN_ID; + const modelIndex = model.findIndex((entry) => { + return entry.appendIndex === operation.target; + }); + + store.remove(id); + if (modelIndex !== -1) { + const [removed] = model.splice(modelIndex, 1); + expectedClosed.push({ + appendIndex: removed!.appendIndex, + props: removed!.props, + }); + expectedErrors += Number(removed!.throwOnClose); + emitted = true; + } + break; + } + case "removeLatest": { + store.removeLatest(); + const removed = model.pop(); + if (removed) { + expectedClosed.push({ + appendIndex: removed.appendIndex, + props: removed.props, + }); + expectedErrors += Number(removed.throwOnClose); + emitted = true; + } + break; + } + case "removeAll": { + store.removeAll(); + if (model.length > 0) { + for (const removed of model) { + expectedClosed.push({ + appendIndex: removed.appendIndex, + props: removed.props, + }); + expectedErrors += Number(removed.throwOnClose); + } + model.splice(0); + emitted = true; + } + break; + } + } + + if (emitted && subscribed) { + expectedNotifications += 1; + } + + const snapshot = store.getSnapshot(); + expect( + snapshot.map(({ id, props }) => { + return { id, props }; + }), + ).toEqual( + model.map(({ id, props }) => { + return { id, props }; + }), + ); + expect(snapshot).toBe(store.getSnapshot()); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(snapshot === previousSnapshot).toBe(!emitted); + expect(store.getServerSnapshot()).toBe(serverSnapshot); + expect(serverSnapshot).toEqual([]); + expect(Object.isFrozen(serverSnapshot)).toBe(true); + expect(opened).toEqual(expectedOpened); + expect(closed).toEqual(expectedClosed); + expect(notifications).toBe(expectedNotifications); + expect(consoleError).toHaveBeenCalledTimes(expectedErrors); + }); + + unsubscribe(); + }, + ), + { numRuns: 200 }, + ); + } finally { + consoleError.mockRestore(); + } + }); + + test("enforces arbitrary valid stack capacities without partially appending", () => { + const capacityCaseArbitrary = fc.integer({ min: 0, max: 50 }).chain((capacity) => { + return fc.tuple( + fc.constant(capacity), + fc.array(propsArbitrary, { minLength: capacity + 1, maxLength: capacity + 1 }), + ); + }); + + fc.assert( + fc.property(capacityCaseArbitrary, ([capacity, propsList]) => { + const store = createStore({ maxStackSize: capacity }); + let notifications = 0; + let rejectedOpenCalls = 0; + store.subscribe(() => { + notifications += 1; + }); + + for (const props of propsList.slice(0, capacity)) { + store.append({ component: Noop, props, callbacks: {} }); + } + + const snapshotAtCapacity = store.getSnapshot(); + const overflowProps = propsList[capacity]!; + expect(() => { + store.append({ + component: Noop, + props: overflowProps, + callbacks: { + onOpen: () => { + rejectedOpenCalls += 1; + }, + }, + }); + }).toThrow(`Maximum modal stack size (${capacity}) exceeded.`); + + expect(store.getSnapshot()).toBe(snapshotAtCapacity); + expect(store.getSnapshot()).toHaveLength(capacity); + expect(notifications).toBe(capacity); + expect(rejectedOpenCalls).toBe(0); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/packages/slottable/package.json b/packages/slottable/package.json index ab754fc..12f8fcc 100644 --- a/packages/slottable/package.json +++ b/packages/slottable/package.json @@ -66,6 +66,7 @@ "@vitejs/plugin-react": "6.0.5", "@vitest/coverage-v8": "catalog:", "@zemd/tsconfig": "catalog:", + "fast-check": "catalog:", "happy-dom": "20.11.1", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/slottable/src/use-slot.property.test.tsx b/packages/slottable/src/use-slot.property.test.tsx new file mode 100644 index 0000000..1a38e20 --- /dev/null +++ b/packages/slottable/src/use-slot.property.test.tsx @@ -0,0 +1,197 @@ +import { render } from "@testing-library/react"; +import * as fc from "fast-check"; +import { describe, expect, test } from "vitest"; +import { Slot } from "./slot"; +import { useSlot } from "./use-slot"; + +const fuzzValue = fc.oneof( + fc.string({ maxLength: 64 }), + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), +); + +const fuzzPropName = fc.stringMatching(/^[a-z][a-z0-9]{0,8}$/).map((name) => `fuzz_${name}`); + +const fuzzProps = fc.dictionary(fuzzPropName, fuzzValue, { maxKeys: 8 }); + +describe("slottable fuzz properties", () => { + test("useSlot always applies the documented prop precedence", () => { + fc.assert( + fc.property( + fuzzProps, + fuzzProps, + fuzzProps, + fuzzValue, + fuzzValue, + fuzzValue, + ( + componentProps, + configuredProps, + optionProps, + componentCollision, + configuredCollision, + optionCollision, + ) => { + let receivedProps: Readonly> | undefined; + let selectedSlotRendered = false; + let fallbackSlotRendered = false; + + const SelectedSlot = (props: Readonly>) => { + selectedSlotRendered = true; + receivedProps = props; + return null; + }; + const FallbackSlot = () => { + fallbackSlotRendered = true; + return null; + }; + const Harness = () => { + const renderRoot = useSlot( + "root", + { + slots: { root: SelectedSlot }, + slotProps: { + root: { ...configuredProps, collision: configuredCollision }, + }, + }, + { + slot: FallbackSlot, + ...optionProps, + collision: optionCollision, + }, + ); + + return <>{renderRoot({ ...componentProps, collision: componentCollision })}; + }; + + const view = render(); + try { + expect(selectedSlotRendered).toBe(true); + expect(fallbackSlotRendered).toBe(false); + expect(receivedProps).toStrictEqual({ + ...componentProps, + ...configuredProps, + ...optionProps, + collision: optionCollision, + }); + expect(receivedProps).not.toHaveProperty("slot"); + } finally { + view.unmount(); + } + }, + ), + ); + }); + + test("useSlot resolves arbitrary slot names without leaking adjacent slot props", () => { + const slotEntries = fc.uniqueArray( + fc.record({ name: fuzzPropName, configuredValue: fuzzValue }), + { + minLength: 1, + maxLength: 8, + selector: (entry) => entry.name, + }, + ); + + fc.assert( + fc.property(slotEntries, fc.nat(), fuzzValue, (entries, index, componentValue) => { + const selected = entries[index % entries.length]!; + const renderedNames: string[] = []; + let receivedProps: Readonly> | undefined; + const slots: Record>) => null> = {}; + const slotProps: Record> = {}; + + for (const entry of entries) { + slots[entry.name] = (props) => { + renderedNames.push(entry.name); + receivedProps = props; + return null; + }; + slotProps[entry.name] = { selectedValue: entry.configuredValue }; + } + + const Harness = () => { + const renderSelected = useSlot(selected.name, { slots, slotProps }); + return <>{renderSelected({ selectedValue: componentValue })}; + }; + + const view = render(); + try { + expect(renderedNames).toEqual([selected.name]); + expect(receivedProps).toStrictEqual({ selectedValue: selected.configuredValue }); + } finally { + view.unmount(); + } + }), + ); + }); + + test("Slot preserves resolution and prop precedence for defaults and overrides", () => { + fc.assert( + fc.property( + fc.boolean(), + fuzzProps, + fuzzProps, + fuzzValue, + fuzzValue, + fc.string({ maxLength: 64 }), + ( + useOverride, + directProps, + configuredProps, + directCollision, + configuredCollision, + child, + ) => { + let renderedSlot: "default" | "override" | undefined; + let receivedProps: Readonly> | undefined; + + const DefaultSlot = (props: Readonly>) => { + renderedSlot = "default"; + receivedProps = props; + return null; + }; + const OverrideSlot = (props: Readonly>) => { + renderedSlot = "override"; + receivedProps = props; + return null; + }; + const slots: Record = useOverride + ? { root: OverrideSlot } + : {}; + + const view = render( + + {child} + , + ); + + try { + expect(renderedSlot).toBe(useOverride ? "override" : "default"); + expect(receivedProps).toStrictEqual({ + ...directProps, + children: child, + ...configuredProps, + collision: configuredCollision, + }); + } finally { + view.unmount(); + } + }, + ), + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db2d2ab..19891e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@zemd/tsconfig': specifier: 2.1.0 version: 2.1.0 + fast-check: + specifier: 4.9.0 + version: 4.9.0 react: specifier: 19.2.8 version: 19.2.8 @@ -86,6 +89,9 @@ importers: '@zemd/tsconfig': specifier: 'catalog:' version: 2.1.0 + fast-check: + specifier: 'catalog:' + version: 4.9.0 happy-dom: specifier: 20.11.1 version: 20.11.1 @@ -165,6 +171,9 @@ importers: '@zemd/tsconfig': specifier: 'catalog:' version: 2.1.0 + fast-check: + specifier: 'catalog:' + version: 4.9.0 happy-dom: specifier: 20.11.1 version: 20.11.1 @@ -1343,6 +1352,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1592,6 +1605,9 @@ packages: engines: {node: '>=18'} hasBin: true + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -2616,6 +2632,10 @@ snapshots: expect-type@1.4.0: {} + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -2856,6 +2876,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + pure-rand@8.4.2: {} + quansync@1.0.0: {} react-dom@19.2.8(react@19.2.8): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7850f6f..82b9e3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ catalog: "@types/react-dom": 19.2.4 "@vitest/coverage-v8": 4.1.10 "@zemd/tsconfig": 2.1.0 + fast-check: 4.9.0 react: 19.2.8 react-dom: 19.2.8 tsdown: 0.22.14