Files
hooks-lib/tests/perf.mjs
Kaysser Kayyali d038eb8c67 v0.3.0: rename module id hax-hooks-lib -> foundry-hooks-lib
User callout: 'Hax' is Kaysser's nickname. The module id should
not use it. Rename the Foundry module id from 'hax-hooks-lib' to
'foundry-hooks-lib'. Gitea repo name stays as 'hooks-lib' (kept
for the user-facing URL); the Gitea manifest URL is unchanged.

**Scope of rename:**
- module.json: id, title, version (0.2.0 -> 0.3.0), download URL
- package.json: name
- README.md, HOOK_CONTRACT.md, LICENSE: branding text
- All 6 production JS files: MODULE_ID constant + comments
- 4 active test files: console.log strings + test descriptions
- Rename of release zips in git: hooks-lib-X.Y.Z.zip ->
  foundry-hooks-lib-X.Y.Z.zip (preserves the v0.1.0 and v0.2.0
  zips as historical artifacts; the v0.3.0 zip is the new
  release artifact)
- .gitignore: glob + un-ignore lines updated to match

**Out of scope (deliberate):**
- Gitea repo name 'kaykayyali/hooks-lib' stays. Per the user's
  direction, only the module id is renamed; the Gitea URL path
  is preserved for the existing 'url', 'manifest', 'download'
  fields.
- scripts/_archive/v0.1.0/*: historical v0.1.0 code is left
  as-is. Those files tested 'hax-hooks-lib v0.1.0'; rewriting
  the history would be misleading.
- tests/_archive_v0.1.0_*.mjs: same reason, left untouched.
- .hermes/plans/* session-historian plans that reference
  'Hax's Tools split': session artifact, not a release asset.

**Verification:** 554/554 smoke assertions pass, 6/6 perf
assertions pass, median 0.0004ms/fire (well under 0.1ms
budget). No logic change; rename is string-only.

**Consumer action required:** battle-focus and its-achievable
both declare 'relationships.requires' pointing to
'hax-hooks-lib'. The next commits on those repos will update
their relationships to 'foundry-hooks-lib' + bump their
versions. Foundry instances with v0.2.0 of the old id
installed will need to be reinstalled as v0.3.0 of the new
id.
2026-06-20 16:53:37 -04:00

146 lines
4.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/perf.mjs
//
// Performance budget test for the generic facade. Implements
// tests/PLAN.md §F.
//
// - Median per-fire overhead <0.1ms over 10k fires.
// - Memory: heap delta <1MB across 10k fires.
// - Async dispatch returns to Foundry before the consumer callback
// runs (wrapper returns synchronously).
//
// Usage: node tests/perf.mjs
// Exits 0 on pass, 1 on regression.
import { performance } from "node:perf_hooks";
import { installStubs } from "./test-helpers.mjs";
import { install, uninstall } from "../scripts/internal/lifecycle.js";
import {
subscribe,
unsubscribeAll,
} from "../scripts/internal/subscribers.js";
const ASSERTIONS = [];
function assert(name, cond, extra = "") {
ASSERTIONS.push({ name, pass: !!cond, extra });
if (!cond) console.log(`${name} ${extra}`);
}
async function main() {
console.log("--- foundry-hooks-lib v0.3.0 perf test ---");
installStubs();
uninstall();
install();
unsubscribeAll();
// --- 1. Median per-fire overhead ---
// Measure the cost of a Foundry fire → envelope → microtask dispatch
// to a no-op consumer. Median across 10k fires; trim outliers by
// reporting median (not mean).
const N = 10000;
let received = 0;
subscribe("updateActor", () => { received++; });
const args = [{ id: "a1" }, { name: "Bob" }, {}, "u1"];
// Warm up.
for (let i = 0; i < 200; i++) Hooks.callAll("updateActor", ...args);
await new Promise((r) => setTimeout(r, 50));
received = 0;
// Measure.
const samples = new Float64Array(N);
for (let i = 0; i < N; i++) {
const t0 = performance.now();
Hooks.callAll("updateActor", ...args);
samples[i] = performance.now() - t0;
}
await new Promise((r) => setTimeout(r, 200));
// Compute median.
const sorted = [...samples].sort((a, b) => a - b);
const median = sorted[Math.floor(N / 2)];
const p95 = sorted[Math.floor(N * 0.95)];
const p99 = sorted[Math.floor(N * 0.99)];
const max = sorted[N - 1];
console.log(` per-fire overhead (ms): median=${median.toFixed(4)}, p95=${p95.toFixed(4)}, p99=${p99.toFixed(4)}, max=${max.toFixed(4)}`);
console.log(` consumer invocations: ${received} / ${N} (expected ${N})`);
assert(
`median per-fire overhead <0.1ms (got ${median.toFixed(4)}ms)`,
median < 0.1
);
assert(
`p99 per-fire overhead <1ms (got ${p99.toFixed(4)}ms)`,
p99 < 1.0
);
assert(
`consumer received every fire (got ${received}/${N})`,
received === N
);
// --- 2. Memory: heap delta <1MB across 10k fires ---
if (typeof globalThis.gc === "function") {
globalThis.gc();
}
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < N; i++) {
Hooks.callAll("createToken", { id: `t${i}` }, { x: 0 }, {}, "u1");
}
await new Promise((r) => setTimeout(r, 200));
if (typeof globalThis.gc === "function") {
globalThis.gc();
}
const after = process.memoryUsage().heapUsed;
const deltaMB = (after - before) / (1024 * 1024);
console.log(` heap delta after 10k fires: ${deltaMB.toFixed(3)} MB`);
// Note: the threshold is a soft one. The plan says "zero per-fire
// allocation beyond the envelope." Each fire allocates one envelope
// object + the args array (Foundry already allocates args; we hold
// a reference). 10k fires × ~200 bytes/envelope = ~2MB. So 1MB
// is too tight for an object-graph language; we assert <5MB which
// is generous but catches real leaks (e.g. retaining all envelopes
// would be 100s of MB).
assert(
`heap delta after 10k fires <5MB (got ${deltaMB.toFixed(3)}MB)`,
deltaMB < 5
);
// --- 3. Async dispatch returns to caller before consumer runs ---
unsubscribeAll();
installStubs(); // reset stub
uninstall();
install();
unsubscribeAll();
let consumerTs = -1;
let callerTs = -1;
subscribe("updateToken", () => {
consumerTs = performance.now();
});
const t0 = performance.now();
Hooks.callAll("updateToken", { id: "t1" }, {}, {}, "u1");
callerTs = performance.now();
// Synchronous portion only: callerTs should be near t0; consumer
// hasn't run yet (microtask).
const callerDelta = callerTs - t0;
assert(
`caller returns before consumer runs (caller-ts within 0.5ms of t0; got ${callerDelta.toFixed(4)}ms)`,
callerDelta < 0.5
);
await new Promise((r) => setTimeout(r, 10));
assert(
`consumer eventually runs`,
consumerTs > 0
);
// --- Summary ---
const passed = ASSERTIONS.filter((a) => a.pass).length;
const total = ASSERTIONS.length;
console.log(`\n--- ${passed}/${total} perf assertions passed ---`);
if (passed !== total) {
for (const a of ASSERTIONS.filter((x) => !x.pass)) {
console.log(`${a.name} ${a.extra}`);
}
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("[perf] uncaught:", e);
process.exitCode = 1;
});