From 6dd598b4b7ae7b2f480a0f5d9d59db275b91777a Mon Sep 17 00:00:00 2001 From: acossa Date: Mon, 11 May 2026 10:25:41 +0200 Subject: [PATCH] test(property): add core runtime invariant checks --- package-lock.json | 50 ++++ package.json | 2 + .../property/core-invariants.property.test.js | 227 ++++++++++++++++++ vitest.config.ts | 2 +- 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 tests/property/core-invariants.property.test.js diff --git a/package-lock.json b/package-lock.json index 2f58281..924851d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@types/node": "25.6.1", "@vitest/coverage-v8": "4.1.5", "esbuild": "0.28.0", + "fast-check": "4.7.0", "typescript": "6.0.3", "vitest": "4.1.5", "wrangler": "4.89.1" @@ -1243,6 +1244,7 @@ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -1632,6 +1634,7 @@ "integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1642,6 +1645,7 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -1874,6 +1878,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1929,6 +1934,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2412,6 +2440,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2448,6 +2477,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -2673,6 +2719,7 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -2683,6 +2730,7 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -2761,6 +2809,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -2869,6 +2918,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, diff --git a/package.json b/package.json index 40b02ab..174f4e1 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "check:release": "npm run build && node scripts/check-release-provenance.mjs --registry-dry-run", "bench:articles": "node benchmarks/articles/run-all.mjs", "test:evidence": "node tests/evidence/run-all.mjs", + "test:property": "npm run build && vitest run tests/property", "pack:dry": "npm pack --dry-run --json", "sample:1b": "npm run build && node samples/1b-stream.sample.js", "sample:concurrency": "npm run build && node samples/concurrency-budget.sample.js", @@ -144,6 +145,7 @@ "@types/node": "25.6.1", "@vitest/coverage-v8": "4.1.5", "esbuild": "0.28.0", + "fast-check": "4.7.0", "typescript": "6.0.3", "vitest": "4.1.5", "wrangler": "4.89.1" diff --git a/tests/property/core-invariants.property.test.js b/tests/property/core-invariants.property.test.js new file mode 100644 index 0000000..86a38bc --- /dev/null +++ b/tests/property/core-invariants.property.test.js @@ -0,0 +1,227 @@ +/** + * Property tests for core WorkIt runtime invariants. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * These tests use bounded, seeded fast-check properties against the built + * public package. They are intended to find schedule-sensitive regressions + * without turning normal CI into an unbounded fuzz run. + */ + +import { test } from "vitest"; +import assert from "node:assert/strict"; +import fc from "fast-check"; +import { + CancellationError, + TimeoutError, + run, + work, +} from "../../dist/index.js"; + +const PROPERTY_RUNS = 35; + +const sleep = (ms, signal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); + +const delayVector = (maxLength = 32) => + fc.array(fc.integer({ min: 0, max: 3 }), { minLength: 1, maxLength }) + .chain((delays) => fc.record({ + delays: fc.constant(delays), + concurrency: fc.integer({ min: 1, max: Math.min(8, delays.length) }), + })); + +test("property: run.pool preserves order and never exceeds the concurrency cap", async () => { + await fc.assert( + fc.asyncProperty(delayVector(), async ({ delays, concurrency }) => { + let active = 0; + let maxActive = 0; + + const tasks = delays.map((delay, index) => async () => { + active++; + maxActive = Math.max(maxActive, active); + try { + await sleep(delay); + return index; + } finally { + active--; + } + }); + + const output = await run.pool(concurrency, tasks); + + assert.deepEqual(output, delays.map((_delay, index) => index)); + assert.ok(maxActive <= concurrency, `maxActive=${maxActive}, concurrency=${concurrency}`); + assert.equal(active, 0); + }), + { numRuns: PROPERTY_RUNS, seed: 0x5EED01 } + ); +}); + +test("property: work().inParallel preserves input order and respects the concurrency cap", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + value: fc.integer({ min: -1_000, max: 1_000 }), + delay: fc.integer({ min: 0, max: 3 }), + }), + { minLength: 1, maxLength: 32 } + ).chain((items) => fc.record({ + items: fc.constant(items), + concurrency: fc.integer({ min: 1, max: Math.min(8, items.length) }), + })), + async ({ items, concurrency }) => { + let active = 0; + let maxActive = 0; + + const output = await work(items) + .inParallel(concurrency) + .do(async (item) => { + active++; + maxActive = Math.max(maxActive, active); + try { + await sleep(item.delay); + return item.value * 2; + } finally { + active--; + } + }); + + assert.equal(output.mode, "fail"); + assert.deepEqual(output.results, items.map((item) => item.value * 2)); + assert.ok(maxActive <= concurrency, `maxActive=${maxActive}, concurrency=${concurrency}`); + assert.equal(active, 0); + } + ), + { numRuns: PROPERTY_RUNS, seed: 0x5EED02 } + ); +}); + +test("property: run.race cancels every cooperative loser with a typed race_lost reason", async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + loserCount: fc.integer({ min: 1, max: 8 }), + winnerOffset: fc.integer({ min: 0, max: 8 }), + }), + async ({ loserCount, winnerOffset }) => { + const taskCount = loserCount + 1; + const winnerIndex = winnerOffset % taskCount; + const cancelled = []; + + const tasks = Array.from({ length: taskCount }, (_, index) => { + if (index === winnerIndex) { + return async () => { + await Promise.resolve(); + return "winner"; + }; + } + + return async (ctx) => { + try { + await sleep(1_000, ctx.signal); + return "loser"; + } catch (err) { + if (err instanceof CancellationError && err.reason.kind === "race_lost") { + cancelled.push({ index, winnerId: err.reason.winnerId }); + } + throw err; + } + }; + }); + + assert.equal(await run.race(tasks), "winner"); + assert.equal(cancelled.length, loserCount); + assert.ok(cancelled.every((entry) => typeof entry.winnerId === "string" && entry.winnerId.length > 0)); + } + ), + { numRuns: PROPERTY_RUNS, seed: 0x5EED03 } + ); +}); + +test("property: run.retry stops immediately when a cancellation error is thrown", async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 5 }), async (cancelAtAttempt) => { + let attempts = 0; + const tag = `property-cancel-${cancelAtAttempt}`; + + await assert.rejects( + run.group(async (task) => task(run.retry(async () => { + attempts++; + if (attempts === cancelAtAttempt) { + throw new CancellationError({ kind: "manual", tag }); + } + throw new Error(`transient-${attempts}`); + }, { + times: cancelAtAttempt + 3, + backoff: "fixed", + initialDelay: 1, + maxDelay: 1, + jitter: false, + }))), + (err) => err instanceof CancellationError + && err.reason.kind === "manual" + && err.reason.tag === tag + ); + + assert.equal(attempts, cancelAtAttempt); + }), + { numRuns: PROPERTY_RUNS, seed: 0x5EED04 } + ); +}); + +test("property: timeout preserves a typed timeout cancellation reason", async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 6 }), async (timeoutMs) => { + await assert.rejects( + run.group(async (task) => task(run.timeout(async (ctx) => { + await sleep(timeoutMs + 50, ctx.signal); + return "late"; + }, timeoutMs))), + (err) => err instanceof TimeoutError + && err.reason.kind === "timeout" + && err.reason.timeoutMs === timeoutMs + ); + }), + { numRuns: PROPERTY_RUNS, seed: 0x5EED05 } + ); +}); + +test("property: the first cancellation reason remains authoritative", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.integer({ min: 0, max: 10_000 }), { minLength: 1, maxLength: 8 }), + async (rawTags) => { + const tags = rawTags.map((tag) => `tag-${tag}`); + const observed = []; + + await assert.rejects( + run.group(async (task) => { + await task(async (ctx) => { + ctx.scope.onCancel((reason) => observed.push(reason)); + for (const tag of tags) { + ctx.scope.cancel({ kind: "manual", tag }); + } + await sleep(50, ctx.signal); + }); + }), + CancellationError + ); + + assert.deepEqual(observed, [{ kind: "manual", tag: tags[0] }]); + } + ), + { numRuns: PROPERTY_RUNS, seed: 0x5EED06 } + ); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 62c9bd6..7f5cd63 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,7 +12,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["tests/unit/*.test.js"], + include: ["tests/unit/*.test.js", "tests/property/*.test.js"], coverage: { provider: "v8", reporter: ["text", "json"],