@forwardimpact/libharness 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,99 @@
1
1
  # libharness
2
2
 
3
- Shared test harness and mock infrastructure.
3
+ Shared test fixtures, mocks, and assertion helpers for the monorepo. Imported by
4
+ `*.test.js` files across libraries, products, and services so test code stays
5
+ consistent and test authors stop reinventing the same helpers.
4
6
 
5
- ## Getting Started
7
+ Runner-independent: the mock primitive `spy()` does not depend on either
8
+ `node:test` or `bun:test`, so the suite runs under both. See spec 650.
9
+
10
+ ## Usage
11
+
12
+ ```js
13
+ import {
14
+ // Mock primitive (replaces node:test's mock.fn)
15
+ spy,
16
+ // Config / storage / logger / fs
17
+ createMockConfig,
18
+ createMockStorage,
19
+ createSilentLogger,
20
+ createMockFs,
21
+ // gRPC / RPC
22
+ createMockGrpcFn,
23
+ MockMetadata,
24
+ createMockObserverFn,
25
+ createMockTracer,
26
+ createMockAuthFn,
27
+ // Clients
28
+ createMockMemoryClient,
29
+ createMockLlmClient,
30
+ createMockAgentClient,
31
+ createMockVectorClient,
32
+ createMockGraphClient,
33
+ createMockToolClient,
34
+ // Infra
35
+ createMockSupabaseClient,
36
+ createMockS3Client,
37
+ createTurtleHelpers,
38
+ createMockProcess,
39
+ withSilentConsole,
40
+ createMockQueries,
41
+ // Framework fixtures (pathway data)
42
+ createTestFramework,
43
+ createTestLevel,
44
+ createTestSkill,
45
+ createTestDiscipline,
46
+ createTestTrack,
47
+ createTestBehaviour,
48
+ createTestCapability,
49
+ createTestDriver,
50
+ createTestPerson,
51
+ createTestRoster,
52
+ createTestEvidenceRow,
53
+ // libeval stream/message helpers
54
+ createToolUseMsg,
55
+ createTextBlockMsg,
56
+ createTestTrace,
57
+ collectStream,
58
+ stripAnsi,
59
+ writeLines,
60
+ createMockAgentQuery,
61
+ // Assertions
62
+ assertThrowsMessage,
63
+ assertRejectsMessage,
64
+ createDeferred,
65
+ // Caching
66
+ memoizeAsync,
67
+ memoizeOnSubject,
68
+ } from "@forwardimpact/libharness";
69
+ ```
70
+
71
+ The full export list lives in `src/index.js`. Subpath entries
72
+ `@forwardimpact/libharness/fixture` and `@forwardimpact/libharness/mock` remain
73
+ for narrower imports.
74
+
75
+ ## When to extend libharness
76
+
77
+ Before adding a helper locally in a test file, check `src/index.js`. If the
78
+ helper doesn't exist and would be reused across two or more files, add it to
79
+ libharness in the same PR instead of inlining. See
80
+ [CONTRIBUTING.md](../../CONTRIBUTING.md) READ-DO and DO-CONFIRM checklists for
81
+ the enforced policy and `scripts/check-libharness.mjs` for the pre-commit guard
82
+ that flags inline reimplementations.
83
+
84
+ ## `spy` vs `node:test`'s `mock.fn`
85
+
86
+ `spy()` matches `mock.fn`'s shape exactly:
6
87
 
7
88
  ```js
8
- import { createFixture } from '@forwardimpact/libharness/fixture';
9
- import { createMock } from '@forwardimpact/libharness/mock';
89
+ const fn = spy((x) => x * 2);
90
+ fn(5);
91
+ fn.mock.calls[0].arguments; // [5]
92
+ fn.mock.calls[0].result; // 10
93
+ fn.mock.callCount(); // 1
94
+ fn.mock.resetCalls();
95
+ fn.mock.mockImplementation((x) => x + 1);
10
96
  ```
97
+
98
+ Prefer `spy` over `node:test`'s `mock.fn` — `spy` works under both `bun test`
99
+ (the default runner) and `node --test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libharness",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Shared test harness and mock infrastructure for the Forward Impact monorepo",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -14,7 +14,7 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "test": "bun run node --test test/*.test.js"
17
+ "test": "bun test test/*.test.js"
18
18
  },
19
19
  "keywords": [
20
20
  "test",
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cross-file fixture caching helpers. The test runner currently executes one
3
+ * test file per process, but fixtures loaded inside a file (e.g. starter
4
+ * framework YAML via `createDataLoader().loadAllData(dir)`) are re-parsed for
5
+ * every `test(...)` case unless hoisted. See spec 620.
6
+ */
7
+
8
+ const caches = new WeakMap();
9
+ const stringCaches = new Map();
10
+
11
+ /**
12
+ * Wraps an async factory so it is invoked at most once per unique key.
13
+ *
14
+ * @template T
15
+ * @param {string} key - Cache key. Using the same key returns the cached value.
16
+ * @param {() => Promise<T>} factory - Factory invoked on miss.
17
+ * @returns {Promise<T>}
18
+ */
19
+ export async function memoizeAsync(key, factory) {
20
+ if (stringCaches.has(key)) return stringCaches.get(key);
21
+ const promise = Promise.resolve().then(factory);
22
+ stringCaches.set(key, promise);
23
+ try {
24
+ return await promise;
25
+ } catch (err) {
26
+ stringCaches.delete(key);
27
+ throw err;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Caches the result of `fn(subject)` keyed by identity of `subject`. Useful
33
+ * for expensive derivations over a frozen input object.
34
+ *
35
+ * @template S, T
36
+ * @param {S} subject
37
+ * @param {(subject: S) => T} fn
38
+ * @returns {T}
39
+ */
40
+ export function memoizeOnSubject(subject, fn) {
41
+ if (!caches.has(subject)) caches.set(subject, fn(subject));
42
+ return caches.get(subject);
43
+ }
44
+
45
+ /**
46
+ * Clears all memoization caches. Only useful in self-tests of this helper.
47
+ */
48
+ export function __resetMemoCaches() {
49
+ stringCaches.clear();
50
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Test helpers for libeval-style streams, tool-use messages, and traces.
3
+ *
4
+ * Before these helpers existed, every test file under libraries/libeval/test
5
+ * inlined its own copy of concludeMsg / redirectMsg / tellMsg / stripAnsi /
6
+ * collect / writeLines / buildTrace. See spec 620.
7
+ */
8
+
9
+ /**
10
+ * Builds a tool-use message envelope as emitted by the agent SDK.
11
+ * Replaces per-file concludeMsg / redirectMsg / tellMsg / shareMsg helpers.
12
+ *
13
+ * @param {string} name - Tool name, e.g. "Conclude", "Redirect", "Tell", "Share".
14
+ * @param {object} input - Tool input payload.
15
+ * @param {object} [options]
16
+ * @param {string} [options.id] - Explicit tool_use id. Defaults to `${name.toLowerCase()}-1`.
17
+ * @returns {object} Assistant message with a single tool_use content block.
18
+ */
19
+ export function createToolUseMsg(name, input, { id } = {}) {
20
+ return {
21
+ type: "assistant",
22
+ message: {
23
+ content: [
24
+ {
25
+ type: "tool_use",
26
+ id: id || `${name.toLowerCase()}-1`,
27
+ name,
28
+ input,
29
+ },
30
+ ],
31
+ },
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Builds an assistant text message envelope as emitted by the agent SDK.
37
+ * @param {string} text - Text content.
38
+ * @returns {object} Assistant message with a single text content block.
39
+ */
40
+ export function createTextBlockMsg(text) {
41
+ return {
42
+ type: "assistant",
43
+ message: {
44
+ content: [{ type: "text", text }],
45
+ },
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Reads a PassThrough / Readable stream to a single string.
51
+ * @param {import("node:stream").Readable} stream
52
+ * @returns {string}
53
+ */
54
+ export function collectStream(stream) {
55
+ const data = stream.read();
56
+ return data ? data.toString() : "";
57
+ }
58
+
59
+ /**
60
+ * Reads a stream and returns non-empty lines.
61
+ * @param {import("node:stream").Readable} stream
62
+ * @returns {string[]}
63
+ */
64
+ export function collectLines(stream) {
65
+ return collectStream(stream).split("\n").filter(Boolean);
66
+ }
67
+
68
+ /**
69
+ * Strips ANSI SGR escape sequences from a string.
70
+ * @param {string} s
71
+ * @returns {string}
72
+ */
73
+ export function stripAnsi(s) {
74
+ // eslint-disable-next-line no-control-regex -- ANSI SGR detection is intentional.
75
+ return s.replace(/\[[0-9;]*m/g, "");
76
+ }
77
+
78
+ /**
79
+ * Writes each line followed by a newline to the writer, then ends it.
80
+ * @param {import("node:stream").Writable} writer
81
+ * @param {string[]} lines
82
+ * @returns {Promise<void>}
83
+ */
84
+ export async function writeLines(writer, lines) {
85
+ for (const line of lines) writer.write(line + "\n");
86
+ await new Promise((resolve) => writer.end(resolve));
87
+ }
88
+
89
+ /**
90
+ * Builds a minimal-but-valid trace object for TraceQuery tests.
91
+ * Replaces a 155-line inline buildTrace in trace-query.test.js.
92
+ *
93
+ * @param {object} [overrides]
94
+ * @param {object} [overrides.metadata]
95
+ * @param {object[]} [overrides.turns]
96
+ * @param {object} [overrides.summary]
97
+ * @returns {object} Trace object.
98
+ */
99
+ export function createTestTrace(overrides = {}) {
100
+ const { metadata = {}, turns = [], summary = {} } = overrides;
101
+ return {
102
+ schema_version: "1.1",
103
+ metadata: {
104
+ session_id: "sess-test",
105
+ model: "claude-opus-4-7",
106
+ started_at: "2026-01-01T00:00:00Z",
107
+ ended_at: "2026-01-01T00:00:05Z",
108
+ tools: [],
109
+ ...metadata,
110
+ },
111
+ turns,
112
+ summary: {
113
+ total_turns: turns.length,
114
+ total_tokens: 0,
115
+ tool_calls: 0,
116
+ errors: 0,
117
+ ...summary,
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Creates an async-generator agent query stub. The shape of `messages`
124
+ * determines per-call behaviour:
125
+ * - Flat array of message objects: yielded on every invocation.
126
+ * - Array of arrays (batches): the Nth invocation yields messages from the
127
+ * Nth batch. Subsequent calls past the last batch repeat the last one.
128
+ *
129
+ * @param {object[] | object[][]} messages
130
+ * @param {(params: object) => void} [onParams] - Invoked with call params.
131
+ * @returns {Function} Async generator mimicking `query({...})`.
132
+ */
133
+ export function createMockAgentQuery(messages, onParams) {
134
+ const isBatched = Array.isArray(messages[0]);
135
+ let callIndex = 0;
136
+ return async function* mockQuery(params) {
137
+ if (onParams) onParams(params);
138
+ if (!isBatched) {
139
+ for (const msg of messages) yield msg;
140
+ return;
141
+ }
142
+ const batch = messages[callIndex] ?? messages[messages.length - 1] ?? [];
143
+ callIndex += 1;
144
+ for (const msg of batch) yield msg;
145
+ };
146
+ }
@@ -5,3 +5,5 @@ export {
5
5
  } from "./assertions.js";
6
6
  export * from "./services.js";
7
7
  export * from "./pathway.js";
8
+ export * from "./eval.js";
9
+ export { memoizeAsync, memoizeOnSubject } from "./cache.js";
@@ -291,3 +291,161 @@ export function createTestBehaviourEntry(overrides = {}) {
291
291
  ...overrides,
292
292
  };
293
293
  }
294
+
295
+ /**
296
+ * Creates a set of mock drivers
297
+ * @returns {object[]}
298
+ */
299
+ export function createTestDrivers() {
300
+ return [
301
+ createTestDriver({
302
+ id: "velocity",
303
+ name: "Velocity",
304
+ contributingSkills: ["coding", "testing"],
305
+ contributingBehaviours: ["collaboration"],
306
+ }),
307
+ createTestDriver({
308
+ id: "quality",
309
+ name: "Quality",
310
+ contributingSkills: ["testing", "architecture"],
311
+ contributingBehaviours: ["communication"],
312
+ }),
313
+ ];
314
+ }
315
+
316
+ /**
317
+ * Creates a set of mock capabilities spanning delivery / scale / ai.
318
+ * @returns {object[]}
319
+ */
320
+ export function createTestCapabilities() {
321
+ return [
322
+ createTestCapability({ id: "delivery", name: "Delivery", ordinalRank: 1 }),
323
+ createTestCapability({ id: "scale", name: "Scale", ordinalRank: 2 }),
324
+ createTestCapability({ id: "ai", name: "AI", ordinalRank: 3 }),
325
+ ];
326
+ }
327
+
328
+ /**
329
+ * Creates a set of mock tracks.
330
+ * @returns {object[]}
331
+ */
332
+ export function createTestTracks() {
333
+ return [
334
+ createTestTrack({
335
+ id: "platform",
336
+ name: "Platform",
337
+ skillModifiers: { scale: 1, delivery: -1 },
338
+ }),
339
+ createTestTrack({
340
+ id: "forward_deployed",
341
+ name: "Forward Deployed",
342
+ skillModifiers: { delivery: 1, scale: -1 },
343
+ }),
344
+ ];
345
+ }
346
+
347
+ /**
348
+ * Creates a set of mock disciplines.
349
+ * @returns {object[]}
350
+ */
351
+ export function createTestDisciplines() {
352
+ return [
353
+ createTestDiscipline({
354
+ id: "software_engineering",
355
+ name: "Software Engineering",
356
+ roleTitle: "Software Engineer",
357
+ }),
358
+ ];
359
+ }
360
+
361
+ /**
362
+ * Builds a complete mock framework (capabilities, skills, disciplines, tracks,
363
+ * drivers, behaviours, levels) with sensible defaults. Per-slice overrides
364
+ * replace the default for that slice entirely.
365
+ *
366
+ * @param {object} [overrides]
367
+ * @param {object[]} [overrides.capabilities]
368
+ * @param {object[]} [overrides.skills]
369
+ * @param {object[]} [overrides.disciplines]
370
+ * @param {object[]} [overrides.tracks]
371
+ * @param {object[]} [overrides.drivers]
372
+ * @param {object[]} [overrides.behaviours]
373
+ * @param {object[]} [overrides.levels]
374
+ * @returns {object} Framework with all slices populated.
375
+ */
376
+ export function createTestFramework(overrides = {}) {
377
+ return {
378
+ capabilities: overrides.capabilities ?? createTestCapabilities(),
379
+ skills: overrides.skills ?? createTestSkills(),
380
+ disciplines: overrides.disciplines ?? createTestDisciplines(),
381
+ tracks: overrides.tracks ?? createTestTracks(),
382
+ drivers: overrides.drivers ?? createTestDrivers(),
383
+ behaviours: overrides.behaviours ?? createTestBehaviours(),
384
+ levels: overrides.levels ?? createTestLevels(),
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Creates a mock person (activity layer / people roster entry).
390
+ * @param {object} [overrides]
391
+ * @returns {object}
392
+ */
393
+ export function createTestPerson(overrides = {}) {
394
+ return {
395
+ id: "p-alice",
396
+ name: "Alice",
397
+ email: "alice@example.com",
398
+ level: "mid",
399
+ discipline: "software_engineering",
400
+ track: "platform",
401
+ ...overrides,
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Creates a mock roster (team snapshot).
407
+ * @param {object} [overrides]
408
+ * @returns {object}
409
+ */
410
+ export function createTestRoster(overrides = {}) {
411
+ return {
412
+ team: "t-alpha",
413
+ members: [createTestPerson()],
414
+ ...overrides,
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Creates a mock evidence row shared across map and landmark tests.
420
+ * @param {object} [overrides]
421
+ * @returns {object}
422
+ */
423
+ export function createTestEvidenceRow(overrides = {}) {
424
+ return {
425
+ skill_id: "coding",
426
+ level_id: "mid",
427
+ matched: true,
428
+ artifact_id: "art-1",
429
+ created_at: "2026-01-01T00:00:00Z",
430
+ github_artifacts: [],
431
+ ...overrides,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Creates a mock skill augmented with per-level markers (human + agent arrays).
437
+ * @param {object} [overrides]
438
+ * @returns {object}
439
+ */
440
+ export function createTestSkillWithMarkers(overrides = {}) {
441
+ return {
442
+ ...createTestSkill(),
443
+ markers: {
444
+ foundational: { human: ["writes simple code"], agent: [] },
445
+ working: { human: ["owns features"], agent: ["reviews PRs"] },
446
+ practitioner: { human: ["mentors peers"], agent: [] },
447
+ expert: { human: ["shapes strategy"], agent: [] },
448
+ },
449
+ ...overrides,
450
+ };
451
+ }
@@ -1,4 +1,4 @@
1
- import { mock } from "node:test";
1
+ import { spy } from "./spy.js";
2
2
  import { common } from "@forwardimpact/libtype";
3
3
 
4
4
  /**
@@ -8,13 +8,13 @@ import { common } from "@forwardimpact/libtype";
8
8
  */
9
9
  export function createMockMemoryClient(overrides = {}) {
10
10
  return {
11
- GetWindow: mock.fn(() =>
11
+ GetWindow: spy(() =>
12
12
  Promise.resolve({
13
13
  messages: [{ role: "system", content: "You are an assistant" }],
14
14
  tools: [],
15
15
  }),
16
16
  ),
17
- AppendMemory: mock.fn(() => Promise.resolve({ accepted: "test-id" })),
17
+ AppendMemory: spy(() => Promise.resolve({ accepted: "test-id" })),
18
18
  ...overrides,
19
19
  };
20
20
  }
@@ -26,7 +26,7 @@ export function createMockMemoryClient(overrides = {}) {
26
26
  */
27
27
  export function createMockLlmClient(overrides = {}) {
28
28
  return {
29
- CreateCompletions: mock.fn(() =>
29
+ CreateCompletions: spy(() =>
30
30
  Promise.resolve({
31
31
  id: "test-completion",
32
32
  choices: [
@@ -40,7 +40,7 @@ export function createMockLlmClient(overrides = {}) {
40
40
  usage: { total_tokens: 100 },
41
41
  }),
42
42
  ),
43
- CreateEmbeddings: mock.fn(() =>
43
+ CreateEmbeddings: spy(() =>
44
44
  Promise.resolve({
45
45
  data: [{ index: 0, embedding: [0.1, 0.2, 0.3] }],
46
46
  }),
@@ -56,7 +56,7 @@ export function createMockLlmClient(overrides = {}) {
56
56
  */
57
57
  export function createMockAgentClient(overrides = {}) {
58
58
  return {
59
- ProcessUnary: mock.fn(() =>
59
+ ProcessUnary: spy(() =>
60
60
  Promise.resolve({
61
61
  resource_id: "test-conversation",
62
62
  choices: [
@@ -69,7 +69,7 @@ export function createMockAgentClient(overrides = {}) {
69
69
  ],
70
70
  }),
71
71
  ),
72
- ProcessStream: mock.fn(),
72
+ ProcessStream: spy(),
73
73
  ...overrides,
74
74
  };
75
75
  }
@@ -81,7 +81,7 @@ export function createMockAgentClient(overrides = {}) {
81
81
  */
82
82
  export function createMockTraceClient(overrides = {}) {
83
83
  return {
84
- RecordSpan: mock.fn(() => Promise.resolve()),
84
+ RecordSpan: spy(() => Promise.resolve()),
85
85
  ...overrides,
86
86
  };
87
87
  }
@@ -93,7 +93,7 @@ export function createMockTraceClient(overrides = {}) {
93
93
  */
94
94
  export function createMockVectorClient(overrides = {}) {
95
95
  return {
96
- SearchContent: mock.fn(() =>
96
+ SearchContent: spy(() =>
97
97
  Promise.resolve({
98
98
  identifiers: [],
99
99
  }),
@@ -109,7 +109,7 @@ export function createMockVectorClient(overrides = {}) {
109
109
  */
110
110
  export function createMockGraphClient(overrides = {}) {
111
111
  return {
112
- QueryByPattern: mock.fn(() =>
112
+ QueryByPattern: spy(() =>
113
113
  Promise.resolve({
114
114
  identifiers: [],
115
115
  }),
@@ -125,7 +125,7 @@ export function createMockGraphClient(overrides = {}) {
125
125
  */
126
126
  export function createMockToolClient(overrides = {}) {
127
127
  return {
128
- CallTool: mock.fn(() =>
128
+ CallTool: spy(() =>
129
129
  Promise.resolve({
130
130
  content: "Tool result",
131
131
  }),
package/src/mock/fs.js CHANGED
@@ -1,5 +1,4 @@
1
- import { mock } from "node:test";
2
-
1
+ import { spy } from "./spy.js";
3
2
  /**
4
3
  * Creates a mock filesystem backed by an in-memory Map
5
4
  * @param {Object<string, string>} files - Initial file contents keyed by path
@@ -20,7 +19,7 @@ export function createMockFs(files = {}) {
20
19
  return {
21
20
  data,
22
21
  dirs,
23
- readFile: mock.fn(async (path, encoding) => {
22
+ readFile: spy(async (path, encoding) => {
24
23
  const content = data.get(path);
25
24
  if (content === undefined) {
26
25
  const err = new Error(
@@ -31,13 +30,13 @@ export function createMockFs(files = {}) {
31
30
  }
32
31
  return encoding ? content : Buffer.from(content);
33
32
  }),
34
- writeFile: mock.fn(async (path, content) => {
33
+ writeFile: spy(async (path, content) => {
35
34
  data.set(
36
35
  path,
37
36
  typeof content === "string" ? content : content.toString(),
38
37
  );
39
38
  }),
40
- readdir: mock.fn(async (path) => {
39
+ readdir: spy(async (path) => {
41
40
  const entries = [];
42
41
  const prefix = path.endsWith("/") ? path : `${path}/`;
43
42
  for (const key of data.keys()) {
@@ -51,7 +50,7 @@ export function createMockFs(files = {}) {
51
50
  }
52
51
  return entries;
53
52
  }),
54
- stat: mock.fn(async (path) => {
53
+ stat: spy(async (path) => {
55
54
  if (data.has(path)) {
56
55
  return { isFile: () => true, isDirectory: () => false };
57
56
  }
@@ -64,10 +63,10 @@ export function createMockFs(files = {}) {
64
63
  err.code = "ENOENT";
65
64
  throw err;
66
65
  }),
67
- mkdir: mock.fn(async (path) => {
66
+ mkdir: spy(async (path) => {
68
67
  dirs.add(path);
69
68
  }),
70
- access: mock.fn(async (path) => {
69
+ access: spy(async (path) => {
71
70
  if (!data.has(path) && !dirs.has(path)) {
72
71
  const err = new Error(
73
72
  `ENOENT: no such file or directory, access '${path}'`,
@@ -76,7 +75,7 @@ export function createMockFs(files = {}) {
76
75
  throw err;
77
76
  }
78
77
  }),
79
- copyFile: mock.fn(async (src, dest) => {
78
+ copyFile: spy(async (src, dest) => {
80
79
  const content = data.get(src);
81
80
  if (content === undefined) {
82
81
  const err = new Error(
@@ -87,8 +86,8 @@ export function createMockFs(files = {}) {
87
86
  }
88
87
  data.set(dest, content);
89
88
  }),
90
- existsSync: mock.fn((path) => data.has(path) || dirs.has(path)),
91
- readFileSync: mock.fn((path, encoding) => {
89
+ existsSync: spy((path) => data.has(path) || dirs.has(path)),
90
+ readFileSync: spy((path, encoding) => {
92
91
  const content = data.get(path);
93
92
  if (content === undefined) {
94
93
  const err = new Error(
@@ -99,13 +98,13 @@ export function createMockFs(files = {}) {
99
98
  }
100
99
  return encoding ? content : Buffer.from(content);
101
100
  }),
102
- writeFileSync: mock.fn((path, content) => {
101
+ writeFileSync: spy((path, content) => {
103
102
  data.set(
104
103
  path,
105
104
  typeof content === "string" ? content : content.toString(),
106
105
  );
107
106
  }),
108
- mkdirSync: mock.fn((path) => {
107
+ mkdirSync: spy((path) => {
109
108
  dirs.add(path);
110
109
  }),
111
110
  };
package/src/mock/grpc.js CHANGED
@@ -1,5 +1,4 @@
1
- import { mock } from "node:test";
2
-
1
+ import { spy } from "./spy.js";
3
2
  /**
4
3
  * Creates a mock gRPC factory function
5
4
  * @param {object} overrides - Method overrides
@@ -9,21 +8,21 @@ export function createMockGrpcFn(overrides = {}) {
9
8
  const mockGrpc = {
10
9
  Server: function () {
11
10
  return {
12
- addService: mock.fn(),
13
- bindAsync: mock.fn((uri, creds, callback) => callback(null, 5000)),
14
- tryShutdown: mock.fn((callback) => callback()),
11
+ addService: spy(),
12
+ bindAsync: spy((uri, creds, callback) => callback(null, 5000)),
13
+ tryShutdown: spy((callback) => callback()),
15
14
  ...overrides.server,
16
15
  };
17
16
  },
18
- loadPackageDefinition: mock.fn(() => ({
17
+ loadPackageDefinition: spy(() => ({
19
18
  test: { Test: { service: {} } },
20
19
  })),
21
- makeGenericClientConstructor: mock.fn(() => function () {}),
20
+ makeGenericClientConstructor: spy(() => function () {}),
22
21
  ServerCredentials: {
23
- createInsecure: mock.fn(),
22
+ createInsecure: spy(),
24
23
  },
25
24
  credentials: {
26
- createInsecure: mock.fn(),
25
+ createInsecure: spy(),
27
26
  },
28
27
  status: {
29
28
  OK: 0,
@@ -48,7 +47,7 @@ export function createMockGrpcFn(overrides = {}) {
48
47
  };
49
48
 
50
49
  const mockProtoLoader = {
51
- loadSync: mock.fn(() => ({})),
50
+ loadSync: spy(() => ({})),
52
51
  ...overrides.protoLoader,
53
52
  };
54
53
 
package/src/mock/index.js CHANGED
@@ -25,3 +25,12 @@ export {
25
25
  } from "./clients.js";
26
26
  export { createMockServiceCallbacks } from "./service-callbacks.js";
27
27
  export { createMockFs } from "./fs.js";
28
+ export { spy } from "./spy.js";
29
+ export {
30
+ createMockSupabaseClient,
31
+ createTurtleHelpers,
32
+ createMockProcess,
33
+ withSilentConsole,
34
+ createMockS3Client,
35
+ createMockQueries,
36
+ } from "./infra.js";
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Additional infrastructure mocks for services/products tests.
3
+ *
4
+ * Historically each consumer inlined its own variant. See spec 620 for the
5
+ * call-site inventory.
6
+ */
7
+
8
+ /**
9
+ * Creates a mock Supabase-style client with configurable table and storage
10
+ * behaviour. Covers the patterns found across products/map/test/activity/*.
11
+ *
12
+ * @param {object} [options]
13
+ * @param {Record<string, object>} [options.tables] - Map of table name to
14
+ * override. Each override may expose `select`, `insert`, `upsert`, `delete`
15
+ * as async functions. Unspecified methods return `{ data: [], error: null }`.
16
+ * @param {Record<string, string>} [options.files] - Files exposed via
17
+ * `storage.from(...).list(prefix)` / `.download(path)`.
18
+ * @returns {object} Mock client and call-tracking arrays.
19
+ */
20
+ export function createMockSupabaseClient({ tables = {}, files = {} } = {}) {
21
+ const calls = {
22
+ select: [],
23
+ insert: [],
24
+ upsert: [],
25
+ delete: [],
26
+ download: [],
27
+ list: [],
28
+ };
29
+
30
+ function record(kind, entry) {
31
+ calls[kind].push(entry);
32
+ }
33
+
34
+ return {
35
+ calls,
36
+ from(table) {
37
+ const override = tables[table] ?? {};
38
+ return {
39
+ async select(...args) {
40
+ record("select", { table, args });
41
+ if (override.select) return override.select(...args);
42
+ return { data: [], error: null };
43
+ },
44
+ async insert(rows, opts) {
45
+ record("insert", { table, rows, options: opts });
46
+ if (override.insert) return override.insert(rows, opts);
47
+ return { data: rows, error: null };
48
+ },
49
+ async upsert(rows, opts) {
50
+ record("upsert", {
51
+ table,
52
+ rows,
53
+ onConflict: opts?.onConflict,
54
+ options: opts,
55
+ });
56
+ if (override.upsert) return override.upsert(rows, opts);
57
+ return { data: rows, error: null };
58
+ },
59
+ async delete(...args) {
60
+ record("delete", { table, args });
61
+ if (override.delete) return override.delete(...args);
62
+ return { error: null };
63
+ },
64
+ };
65
+ },
66
+ storage: {
67
+ from() {
68
+ return {
69
+ async list(prefix) {
70
+ record("list", { prefix });
71
+ const names = Object.keys(files)
72
+ .filter((k) => k.startsWith(prefix))
73
+ .map((k) => ({
74
+ name: k.slice(prefix.length),
75
+ created_at: "z",
76
+ }));
77
+ return { data: names, error: null };
78
+ },
79
+ async download(path) {
80
+ record("download", { path });
81
+ const content = files[path];
82
+ if (content === undefined) {
83
+ return { data: null, error: { message: "not found" } };
84
+ }
85
+ return {
86
+ data: { text: async () => content },
87
+ error: null,
88
+ };
89
+ },
90
+ };
91
+ },
92
+ },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Creates Turtle parsing helpers bound to an injected n3 Parser. Keeps
98
+ * libharness free of an n3 dependency while allowing services to share the
99
+ * parseQuads / findAll / findOne idiom.
100
+ *
101
+ * @param {import("n3").Parser | Function} ParserOrInstance - n3 Parser class
102
+ * or a pre-built parser instance.
103
+ * @param {object} [options]
104
+ * @param {string} [options.format="Turtle"] - Parser format.
105
+ * @returns {object} { parseQuads, findAll, findOne }
106
+ */
107
+ export function createTurtleHelpers(
108
+ ParserOrInstance,
109
+ { format = "Turtle" } = {},
110
+ ) {
111
+ const isClass =
112
+ typeof ParserOrInstance === "function" &&
113
+ ParserOrInstance.prototype &&
114
+ typeof ParserOrInstance.prototype.parse === "function";
115
+ const parser = isClass ? new ParserOrInstance({ format }) : ParserOrInstance;
116
+
117
+ function parseQuads(turtle) {
118
+ return parser.parse(turtle);
119
+ }
120
+
121
+ function findAll(quads, { subject, predicate, object } = {}) {
122
+ return quads.filter(
123
+ (q) =>
124
+ (!subject || q.subject.value === subject) &&
125
+ (!predicate || q.predicate.value === predicate) &&
126
+ (!object || q.object.value === object),
127
+ );
128
+ }
129
+
130
+ function findOne(quads, pattern) {
131
+ return findAll(quads, pattern)[0];
132
+ }
133
+
134
+ return { parseQuads, findAll, findOne };
135
+ }
136
+
137
+ /**
138
+ * Creates a minimal mock `process`-like object with env, stdout, stderr,
139
+ * exitCode, and simple write capture.
140
+ *
141
+ * @param {object} [options]
142
+ * @param {Record<string, string>} [options.env]
143
+ * @returns {object}
144
+ */
145
+ export function createMockProcess({ env = {} } = {}) {
146
+ const stdout = { chunks: [], write: (s) => stdout.chunks.push(String(s)) };
147
+ const stderr = { chunks: [], write: (s) => stderr.chunks.push(String(s)) };
148
+ return {
149
+ env: { ...env },
150
+ stdout,
151
+ stderr,
152
+ exitCode: 0,
153
+ exit(code = 0) {
154
+ this.exitCode = code;
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Runs `fn` with `console.log`, `console.info`, and `console.warn` suppressed,
161
+ * returning whatever `fn` returns. Errors still propagate.
162
+ *
163
+ * @template T
164
+ * @param {() => T | Promise<T>} fn
165
+ * @returns {Promise<T>}
166
+ */
167
+ export async function withSilentConsole(fn) {
168
+ const originals = {
169
+ log: console.log,
170
+ info: console.info,
171
+ warn: console.warn,
172
+ };
173
+ console.log = () => {};
174
+ console.info = () => {};
175
+ console.warn = () => {};
176
+ try {
177
+ return await fn();
178
+ } finally {
179
+ console.log = originals.log;
180
+ console.info = originals.info;
181
+ console.warn = originals.warn;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Creates a bag of async query stubs from a plain values object. A function
187
+ * value is passed through untouched; anything else becomes an async function
188
+ * returning that value. Collapses landmark-style `stubQueries` boilerplate.
189
+ *
190
+ * @param {Record<string, unknown>} values
191
+ * @returns {Record<string, Function>}
192
+ */
193
+ export function createMockQueries(values = {}) {
194
+ const out = {};
195
+ for (const [key, val] of Object.entries(values)) {
196
+ out[key] = typeof val === "function" ? val : async () => val;
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * Creates a minimal mock S3 client that records command sends and returns a
203
+ * configurable response.
204
+ *
205
+ * @param {object} [options]
206
+ * @param {(command: object) => unknown} [options.sendFn] - Custom send handler.
207
+ * @returns {object} { client, sends }
208
+ */
209
+ export function createMockS3Client({ sendFn } = {}) {
210
+ const sends = [];
211
+ return {
212
+ sends,
213
+ async send(command) {
214
+ sends.push(command);
215
+ if (sendFn) return sendFn(command);
216
+ return {};
217
+ },
218
+ };
219
+ }
@@ -1,5 +1,4 @@
1
- import { mock } from "node:test";
2
-
1
+ import { spy } from "./spy.js";
3
2
  /**
4
3
  * Creates a mock logger with call tracking
5
4
  * @param {object} options - Logger options
@@ -11,7 +10,7 @@ export function createMockLogger(options = {}) {
11
10
  const capture = options.captureOutput ?? false;
12
11
 
13
12
  const createMethod = (level) =>
14
- mock.fn((appId, msg, attributes) => {
13
+ spy((appId, msg, attributes) => {
15
14
  if (capture) {
16
15
  logs.push({ level, appId, msg, attributes });
17
16
  }
@@ -1,5 +1,4 @@
1
- import { mock } from "node:test";
2
-
1
+ import { spy } from "./spy.js";
3
2
  /**
4
3
  * Creates a mock observer factory
5
4
  * @param {object} logger - Logger to use (optional)
@@ -7,9 +6,9 @@ import { mock } from "node:test";
7
6
  */
8
7
  export function createMockObserverFn(logger = null) {
9
8
  const mockLogger = logger || {
10
- debug: mock.fn(),
11
- info: mock.fn(),
12
- error: mock.fn(),
9
+ debug: spy(),
10
+ info: spy(),
11
+ error: spy(),
13
12
  };
14
13
 
15
14
  return () => ({
@@ -33,20 +32,20 @@ export function createMockObserverFn(logger = null) {
33
32
  */
34
33
  export function createMockTracer(overrides = {}) {
35
34
  return {
36
- startSpan: mock.fn((name, options = {}) => ({
35
+ startSpan: spy((name, options = {}) => ({
37
36
  span_id: `span-${Date.now()}`,
38
37
  trace_id: `trace-${Date.now()}`,
39
38
  name,
40
39
  ...options,
41
40
  })),
42
- startClientSpan: mock.fn((_service, _method) => ({
41
+ startClientSpan: spy((_service, _method) => ({
43
42
  span: {
44
43
  span_id: `client-span-${Date.now()}`,
45
44
  trace_id: `trace-${Date.now()}`,
46
45
  },
47
46
  metadata: { get: () => [], set: () => {} },
48
47
  })),
49
- startServerSpan: mock.fn((_service, _method, _request, metadata) => ({
48
+ startServerSpan: spy((_service, _method, _request, metadata) => ({
50
49
  span_id: `server-span-${Date.now()}`,
51
50
  trace_id: metadata?.get?.("x-trace-id")?.[0] || `trace-${Date.now()}`,
52
51
  })),
@@ -54,8 +53,8 @@ export function createMockTracer(overrides = {}) {
54
53
  run: (span, fn) => fn(),
55
54
  getStore: () => null,
56
55
  }),
57
- endSpan: mock.fn(),
58
- recordError: mock.fn(),
56
+ endSpan: spy(),
57
+ recordError: spy(),
59
58
  ...overrides,
60
59
  };
61
60
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Portable mock function helper. Replaces `mock.fn` from `node:test` so the
3
+ * test suite can run under either node:test or bun:test.
4
+ *
5
+ * Shape matches node:test's `mock.fn` to keep call-inspection sites
6
+ * (`fn.mock.calls[0].arguments`, `fn.mock.callCount()`, `fn.mock.resetCalls()`)
7
+ * unchanged across the codebase.
8
+ *
9
+ * @template T
10
+ * @param {(...args: any[]) => T} [impl] - Initial implementation.
11
+ * @returns {((...args: any[]) => T) & { mock: { calls: Array<{arguments: any[], result?: T, error?: unknown, this: unknown}>, callCount: () => number, resetCalls: () => void, mockImplementation: (newImpl: (...args: any[]) => T) => void } }}
12
+ */
13
+ export function spy(impl) {
14
+ let _impl = impl;
15
+ const calls = [];
16
+ const fn = function (...args) {
17
+ const rec = { arguments: args, this: this };
18
+ if (!_impl) {
19
+ calls.push(rec);
20
+ return undefined;
21
+ }
22
+ try {
23
+ const result = _impl.apply(this, args);
24
+ rec.result = result;
25
+ calls.push(rec);
26
+ return result;
27
+ } catch (err) {
28
+ rec.error = err;
29
+ calls.push(rec);
30
+ throw err;
31
+ }
32
+ };
33
+ fn.mock = {
34
+ calls,
35
+ callCount: () => calls.length,
36
+ resetCalls: () => {
37
+ calls.length = 0;
38
+ },
39
+ mockImplementation: (newImpl) => {
40
+ _impl = newImpl;
41
+ },
42
+ };
43
+ return fn;
44
+ }
@@ -1,5 +1,4 @@
1
- import { mock } from "node:test";
2
-
1
+ import { spy } from "./spy.js";
3
2
  /**
4
3
  * Creates a mock storage instance with tracking
5
4
  * @param {object} overrides - Method overrides
@@ -10,8 +9,8 @@ export function createMockStorage(overrides = {}) {
10
9
 
11
10
  return {
12
11
  data,
13
- exists: mock.fn((key) => Promise.resolve(data.has(key))),
14
- get: mock.fn((key) => {
12
+ exists: spy((key) => Promise.resolve(data.has(key))),
13
+ get: spy((key) => {
15
14
  const value = data.get(key);
16
15
  if (!value) return Promise.reject(new Error("Not found"));
17
16
 
@@ -24,20 +23,20 @@ export function createMockStorage(overrides = {}) {
24
23
  }
25
24
  return Promise.resolve(value);
26
25
  }),
27
- put: mock.fn((key, value) => {
26
+ put: spy((key, value) => {
28
27
  data.set(key, value);
29
28
  return Promise.resolve();
30
29
  }),
31
- append: mock.fn((key, value) => {
30
+ append: spy((key, value) => {
32
31
  const existing = data.get(key) || "";
33
32
  data.set(key, existing ? `${existing}\n${value}` : value);
34
33
  return Promise.resolve();
35
34
  }),
36
- delete: mock.fn((key) => {
35
+ delete: spy((key) => {
37
36
  data.delete(key);
38
37
  return Promise.resolve();
39
38
  }),
40
- findByPrefix: mock.fn(() => Promise.resolve([])),
39
+ findByPrefix: spy(() => Promise.resolve([])),
41
40
  ...overrides,
42
41
  };
43
42
  }