@b4run/testing 0.8.28

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/aimock-runner.d.ts +48 -0
  4. package/dist/aimock-runner.d.ts.map +1 -0
  5. package/dist/aimock-runner.js +81 -0
  6. package/dist/checkpointer-conformance.d.ts +43 -0
  7. package/dist/checkpointer-conformance.d.ts.map +1 -0
  8. package/dist/checkpointer-conformance.js +381 -0
  9. package/dist/fake-embedder.d.ts +9 -0
  10. package/dist/fake-embedder.d.ts.map +1 -0
  11. package/dist/fake-embedder.js +38 -0
  12. package/dist/fixture-builder.d.ts +29 -0
  13. package/dist/fixture-builder.d.ts.map +1 -0
  14. package/dist/fixture-builder.js +43 -0
  15. package/dist/fixture-file.d.ts +14 -0
  16. package/dist/fixture-file.d.ts.map +1 -0
  17. package/dist/fixture-file.js +49 -0
  18. package/dist/harness.d.ts +35 -0
  19. package/dist/harness.d.ts.map +1 -0
  20. package/dist/harness.js +211 -0
  21. package/dist/http-inject.d.ts +22 -0
  22. package/dist/http-inject.d.ts.map +1 -0
  23. package/dist/http-inject.js +55 -0
  24. package/dist/index.d.ts +20 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +19 -0
  27. package/dist/matchers.d.ts +56 -0
  28. package/dist/matchers.d.ts.map +1 -0
  29. package/dist/matchers.js +262 -0
  30. package/dist/memory-conformance.d.ts +13 -0
  31. package/dist/memory-conformance.d.ts.map +1 -0
  32. package/dist/memory-conformance.js +1333 -0
  33. package/dist/memory.d.ts +11 -0
  34. package/dist/memory.d.ts.map +1 -0
  35. package/dist/memory.js +20 -0
  36. package/dist/middleware-harness.d.ts +11 -0
  37. package/dist/middleware-harness.d.ts.map +1 -0
  38. package/dist/middleware-harness.js +35 -0
  39. package/dist/permissions-conformance.d.ts +24 -0
  40. package/dist/permissions-conformance.d.ts.map +1 -0
  41. package/dist/permissions-conformance.js +156 -0
  42. package/dist/record-fixtures.d.ts +21 -0
  43. package/dist/record-fixtures.d.ts.map +1 -0
  44. package/dist/record-fixtures.js +31 -0
  45. package/dist/record.d.ts +12 -0
  46. package/dist/record.d.ts.map +1 -0
  47. package/dist/record.js +22 -0
  48. package/dist/regex-safety.d.ts +2 -0
  49. package/dist/regex-safety.d.ts.map +1 -0
  50. package/dist/regex-safety.js +66 -0
  51. package/dist/run-result.d.ts +111 -0
  52. package/dist/run-result.d.ts.map +1 -0
  53. package/dist/run-result.js +287 -0
  54. package/dist/subprocess.d.ts +25 -0
  55. package/dist/subprocess.d.ts.map +1 -0
  56. package/dist/subprocess.js +223 -0
  57. package/dist/thread-access-harness.d.ts +40 -0
  58. package/dist/thread-access-harness.d.ts.map +1 -0
  59. package/dist/thread-access-harness.js +42 -0
  60. package/dist/threads-conformance.d.ts +18 -0
  61. package/dist/threads-conformance.d.ts.map +1 -0
  62. package/dist/threads-conformance.js +315 -0
  63. package/dist/tool-harness.d.ts +16 -0
  64. package/dist/tool-harness.d.ts.map +1 -0
  65. package/dist/tool-harness.js +25 -0
  66. package/dist/tsconfig.tsbuildinfo +1 -0
  67. package/dist/workspace-harness.d.ts +15 -0
  68. package/dist/workspace-harness.d.ts.map +1 -0
  69. package/dist/workspace-harness.js +43 -0
  70. package/package.json +71 -0
@@ -0,0 +1,315 @@
1
+ import { expect, test } from "vitest";
2
+ const ISO_MS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
3
+ const GENERATED_ID = /^t-[0-9a-f]{8}$/;
4
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ /** Timestamps are app-generated ISO strings; compare as instants, not bytes. */
6
+ const at = (t, field) => Date.parse(t[field]);
7
+ /**
8
+ * The contract every ThreadsStore must satisfy. Run against the sqlite store
9
+ * (in-process, always) and any other backend (e.g. Postgres, gated) so they
10
+ * cannot drift. Pass vitest's `describe`; `makeStore` returns a FRESH empty
11
+ * store per call.
12
+ *
13
+ * Everything here goes through the public interface — never the tables — so a
14
+ * backend is free to choose its own column types (sqlite keeps `metadata` as a
15
+ * TEXT blob, Postgres as `jsonb`) as long as the observable behavior matches.
16
+ */
17
+ export function runThreadsStoreConformance(opts) {
18
+ const { name, makeStore, describe, close } = opts;
19
+ describe(`ThreadsStore conformance: ${name}`, () => {
20
+ test("createThread + getThread round-trips id, metadata and status", async () => {
21
+ const s = await makeStore();
22
+ try {
23
+ const created = await s.createThread({ metadata: { user: "brian" } });
24
+ expect(created.status).toBe("idle");
25
+ expect(created.metadata).toEqual({ user: "brian" });
26
+ const fetched = await s.getThread(created.thread_id);
27
+ expect(fetched?.thread_id).toBe(created.thread_id);
28
+ expect(fetched?.metadata).toEqual({ user: "brian" });
29
+ expect(fetched?.status).toBe("idle");
30
+ }
31
+ finally {
32
+ await close?.(s);
33
+ }
34
+ });
35
+ test("createThread's return value matches what getThread reads back", async () => {
36
+ const s = await makeStore();
37
+ try {
38
+ // The store synthesizes the returned Thread rather than re-reading the
39
+ // row; "synthesized" is only observable as exact equality with the
40
+ // stored row, so that is what the contract pins.
41
+ const created = await s.createThread({ thread_id: "t-synth", metadata: { a: 1 } });
42
+ expect(await s.getThread("t-synth")).toEqual(created);
43
+ }
44
+ finally {
45
+ await close?.(s);
46
+ }
47
+ });
48
+ test("createThread honors an explicit thread_id", async () => {
49
+ const s = await makeStore();
50
+ try {
51
+ const created = await s.createThread({ thread_id: "t-explicit" });
52
+ expect(created.thread_id).toBe("t-explicit");
53
+ expect((await s.getThread("t-explicit"))?.thread_id).toBe("t-explicit");
54
+ }
55
+ finally {
56
+ await close?.(s);
57
+ }
58
+ });
59
+ test("generated ids are t- plus 8 lowercase hex digits, and are unique", async () => {
60
+ const s = await makeStore();
61
+ try {
62
+ const a = await s.createThread({});
63
+ const b = await s.createThread({});
64
+ expect(a.thread_id).toMatch(GENERATED_ID);
65
+ expect(b.thread_id).toMatch(GENERATED_ID);
66
+ expect(a.thread_id).not.toBe(b.thread_id);
67
+ }
68
+ finally {
69
+ await close?.(s);
70
+ }
71
+ });
72
+ test("metadata defaults to an empty object when omitted", async () => {
73
+ const s = await makeStore();
74
+ try {
75
+ const created = await s.createThread({});
76
+ expect(created.metadata).toEqual({});
77
+ expect((await s.getThread(created.thread_id))?.metadata).toEqual({});
78
+ }
79
+ finally {
80
+ await close?.(s);
81
+ }
82
+ });
83
+ test("timestamps are ISO-8601 strings, equal at creation", async () => {
84
+ const s = await makeStore();
85
+ try {
86
+ const created = await s.createThread({});
87
+ expect(created.created_at).toMatch(ISO_MS);
88
+ expect(created.updated_at).toMatch(ISO_MS);
89
+ expect(created.updated_at).toBe(created.created_at);
90
+ const fetched = await s.getThread(created.thread_id);
91
+ expect(fetched?.created_at).toMatch(ISO_MS);
92
+ expect(fetched?.updated_at).toMatch(ISO_MS);
93
+ }
94
+ finally {
95
+ await close?.(s);
96
+ }
97
+ });
98
+ test("getThread returns undefined for a missing id", async () => {
99
+ const s = await makeStore();
100
+ try {
101
+ expect(await s.getThread("t-missing")).toBeUndefined();
102
+ }
103
+ finally {
104
+ await close?.(s);
105
+ }
106
+ });
107
+ test("deleteThread removes the thread and is idempotent", async () => {
108
+ const s = await makeStore();
109
+ try {
110
+ const created = await s.createThread({});
111
+ await s.deleteThread(created.thread_id);
112
+ expect(await s.getThread(created.thread_id)).toBeUndefined();
113
+ await s.deleteThread(created.thread_id); // second delete must not throw
114
+ await s.deleteThread("t-never-existed"); // nor must an unknown id
115
+ expect(await s.listThreads()).toEqual([]);
116
+ }
117
+ finally {
118
+ await close?.(s);
119
+ }
120
+ });
121
+ test("listThreads returns an empty list on an empty store", async () => {
122
+ const s = await makeStore();
123
+ try {
124
+ expect(await s.listThreads()).toEqual([]);
125
+ }
126
+ finally {
127
+ await close?.(s);
128
+ }
129
+ });
130
+ test("listThreads is ordered updated_at DESC and reorders after an update", async () => {
131
+ const s = await makeStore();
132
+ try {
133
+ await s.createThread({ thread_id: "t-a" });
134
+ await sleep(5);
135
+ await s.createThread({ thread_id: "t-b" });
136
+ expect((await s.listThreads()).map((t) => t.thread_id)).toEqual(["t-b", "t-a"]);
137
+ await sleep(5);
138
+ await s.updateStatus("t-a", "busy");
139
+ expect((await s.listThreads()).map((t) => t.thread_id)).toEqual(["t-a", "t-b"]);
140
+ }
141
+ finally {
142
+ await close?.(s);
143
+ }
144
+ });
145
+ test("updateStatus changes the status and bumps updated_at, leaving created_at", async () => {
146
+ const s = await makeStore();
147
+ try {
148
+ const created = await s.createThread({ thread_id: "t-st" });
149
+ await sleep(5);
150
+ await s.updateStatus("t-st", "interrupted");
151
+ const fetched = await s.getThread("t-st");
152
+ expect(fetched?.status).toBe("interrupted");
153
+ expect(at(fetched, "updated_at")).toBeGreaterThan(Date.parse(created.updated_at));
154
+ expect(fetched?.created_at).toBe(created.created_at);
155
+ }
156
+ finally {
157
+ await close?.(s);
158
+ }
159
+ });
160
+ test("updateStatus is a silent no-op for a missing thread", async () => {
161
+ const s = await makeStore();
162
+ try {
163
+ await s.updateStatus("t-missing", "busy");
164
+ expect(await s.getThread("t-missing")).toBeUndefined();
165
+ }
166
+ finally {
167
+ await close?.(s);
168
+ }
169
+ });
170
+ test("updateMetadata merges SHALLOWLY — a nested object is replaced wholesale", async () => {
171
+ const s = await makeStore();
172
+ try {
173
+ await s.createThread({
174
+ thread_id: "t-meta",
175
+ metadata: { user: "brian", nested: { keep: 1, drop: 2 } },
176
+ });
177
+ await s.updateMetadata("t-meta", { route: "/chat#agent", nested: { keep: 9 } });
178
+ // A deep merge would leave `drop: 2` behind. Shallow merge replaces the
179
+ // whole `nested` value — that difference is the contract.
180
+ expect((await s.getThread("t-meta"))?.metadata).toEqual({
181
+ user: "brian",
182
+ route: "/chat#agent",
183
+ nested: { keep: 9 },
184
+ });
185
+ }
186
+ finally {
187
+ await close?.(s);
188
+ }
189
+ });
190
+ test("updateMetadata bumps updated_at (so listThreads reorders)", async () => {
191
+ const s = await makeStore();
192
+ try {
193
+ const created = await s.createThread({ thread_id: "t-mts" });
194
+ await sleep(5);
195
+ await s.updateMetadata("t-mts", { route: "/chat#agent" });
196
+ const fetched = await s.getThread("t-mts");
197
+ expect(at(fetched, "updated_at")).toBeGreaterThan(Date.parse(created.updated_at));
198
+ expect(fetched?.created_at).toBe(created.created_at);
199
+ }
200
+ finally {
201
+ await close?.(s);
202
+ }
203
+ });
204
+ test("updateMetadata with an empty patch keeps the existing metadata", async () => {
205
+ const s = await makeStore();
206
+ try {
207
+ await s.createThread({ thread_id: "t-empty", metadata: { user: "brian" } });
208
+ await s.updateMetadata("t-empty", {});
209
+ expect((await s.getThread("t-empty"))?.metadata).toEqual({ user: "brian" });
210
+ }
211
+ finally {
212
+ await close?.(s);
213
+ }
214
+ });
215
+ test("updateMetadata is a silent no-op for a missing thread", async () => {
216
+ const s = await makeStore();
217
+ try {
218
+ await s.updateMetadata("t-missing", { route: "/chat#agent" });
219
+ expect(await s.getThread("t-missing")).toBeUndefined();
220
+ }
221
+ finally {
222
+ await close?.(s);
223
+ }
224
+ });
225
+ test("a duplicate createThread does not corrupt state", async () => {
226
+ const s = await makeStore();
227
+ try {
228
+ // DELIBERATELY not asserting that the second create throws. sqlite's
229
+ // bare INSERT does, but callers check-then-create (a race across
230
+ // instances), so a multi-writer backend is expected to use an atomic
231
+ // upsert that returns the existing row instead. Both outcomes are
232
+ // conformant; what must hold is that exactly one intact thread remains.
233
+ await s.createThread({ thread_id: "t-dup", metadata: { v: 1 } });
234
+ await s.createThread({ thread_id: "t-dup", metadata: { v: 2 } }).catch(() => undefined);
235
+ const all = await s.listThreads();
236
+ expect(all.filter((t) => t.thread_id === "t-dup")).toHaveLength(1);
237
+ const fetched = await s.getThread("t-dup");
238
+ expect(fetched?.status).toBe("idle");
239
+ expect(fetched?.created_at).toMatch(ISO_MS);
240
+ // WHICH metadata survives is not "either one" — it is pinned by the
241
+ // next case, which is stricter than anything that belongs here.
242
+ }
243
+ finally {
244
+ await close?.(s);
245
+ }
246
+ });
247
+ test("a colliding createThread never applies the caller's metadata", async () => {
248
+ const s = await makeStore();
249
+ try {
250
+ // The two conformant outcomes above diverge by backend: sqlite's bare
251
+ // INSERT throws, a multi-writer backend upserts and hands back the row
252
+ // that is already there. The THIRD outcome — the caller's metadata
253
+ // overwriting the stored row's — is not conformant on either.
254
+ //
255
+ // Thread ids are `t-` plus four random bytes, so collisions are a
256
+ // 32-bit birthday problem, not a hypothetical. B4.run stores each
257
+ // thread's authorization stamp under a reserved metadata key, so a
258
+ // store that let a second create rewrite metadata would let whoever
259
+ // draws (or guesses) a live id restamp someone else's thread and then
260
+ // read it legally.
261
+ await s.createThread({ thread_id: "t-collide", metadata: { owner: "first" } });
262
+ await s
263
+ .createThread({ thread_id: "t-collide", metadata: { owner: "second" } })
264
+ .catch(() => undefined);
265
+ expect((await s.getThread("t-collide"))?.metadata).toEqual({ owner: "first" });
266
+ }
267
+ finally {
268
+ await close?.(s);
269
+ }
270
+ });
271
+ test("updateMetadata leaves a top-level key the patch does not name intact", async () => {
272
+ const s = await makeStore();
273
+ try {
274
+ // The companion to the shallow-merge case above. That one pins what a
275
+ // patch REPLACES; this one pins what it must not touch.
276
+ //
277
+ // `b4:access` is the reserved key B4.run's thread-access stamp lives
278
+ // under (`THREAD_ACCESS_METADATA_KEY`), written once at create and
279
+ // never again. Every later `route` and `parked_route` write is a patch
280
+ // like the two below. If a patch could drop an unrelated key, a thread
281
+ // would silently lose its stamp on its next run and read back as an
282
+ // unstamped legacy thread — which a policy is entitled to treat as
283
+ // admin-only, or as nobody's.
284
+ await s.createThread({
285
+ thread_id: "t-untouched",
286
+ metadata: { "b4:access": { ownerId: "u-1" }, user: "brian" },
287
+ });
288
+ await s.updateMetadata("t-untouched", { route: "/chat#agent" });
289
+ await s.updateMetadata("t-untouched", { parked_route: "/chat#agent" });
290
+ expect((await s.getThread("t-untouched"))?.metadata).toEqual({
291
+ "b4:access": { ownerId: "u-1" },
292
+ parked_route: "/chat#agent",
293
+ route: "/chat#agent",
294
+ user: "brian",
295
+ });
296
+ }
297
+ finally {
298
+ await close?.(s);
299
+ }
300
+ });
301
+ test("threads are independent: deleting one leaves the others intact", async () => {
302
+ const s = await makeStore();
303
+ try {
304
+ await s.createThread({ thread_id: "t-1", metadata: { keep: true } });
305
+ await s.createThread({ thread_id: "t-2" });
306
+ await s.deleteThread("t-2");
307
+ expect((await s.getThread("t-1"))?.metadata).toEqual({ keep: true });
308
+ expect((await s.listThreads()).map((t) => t.thread_id)).toEqual(["t-1"]);
309
+ }
310
+ finally {
311
+ await close?.(s);
312
+ }
313
+ });
314
+ });
315
+ }
@@ -0,0 +1,16 @@
1
+ import type { PermissionsStore } from "@b4run/permissions";
2
+ import type { B4ToolContext } from "@b4run/sdk";
3
+ import { type WorkspaceHarness } from "./workspace-harness.js";
4
+ export interface ToolHarness<I, O> {
5
+ invoke(input: I): Promise<O>;
6
+ readonly workspace: WorkspaceHarness;
7
+ close(): Promise<void>;
8
+ [Symbol.asyncDispose](): Promise<void>;
9
+ }
10
+ export interface ToolHarnessOptions {
11
+ readonly middleware?: Readonly<Record<string, unknown>>;
12
+ readonly workspace?: WorkspaceHarness;
13
+ readonly permissions?: PermissionsStore;
14
+ }
15
+ export declare function createToolHarness<I, O>(tool: (input: I, ctx: B4ToolContext) => Promise<O> | O, opts?: ToolHarnessOptions): Promise<ToolHarness<I, O>>;
16
+ //# sourceMappingURL=tool-harness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-harness.d.ts","sourceRoot":"","sources":["../src/tool-harness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAEtF,MAAM,WAAW,WAAW,CAAC,CAAC,EAAE,CAAC;IAC/B,MAAM,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IAC5B,QAAQ,CAAC,SAAS,EAAE,gBAAgB,CAAA;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACvD,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAA;IACrC,QAAQ,CAAC,WAAW,CAAC,EAAE,gBAAgB,CAAA;CACxC;AAED,wBAAsB,iBAAiB,CAAC,CAAC,EAAE,CAAC,EAC1C,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EACtD,IAAI,CAAC,EAAE,kBAAkB,GACxB,OAAO,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CA2B5B"}
@@ -0,0 +1,25 @@
1
+ import { createWorkspaceHarness } from "./workspace-harness.js";
2
+ export async function createToolHarness(tool, opts) {
3
+ const ownsWorkspace = opts?.workspace === undefined;
4
+ const workspace = opts?.workspace ??
5
+ (await createWorkspaceHarness(opts?.permissions ? { permissions: opts.permissions } : undefined));
6
+ const controller = new AbortController();
7
+ const close = async () => {
8
+ controller.abort();
9
+ if (ownsWorkspace)
10
+ await workspace.close();
11
+ };
12
+ return {
13
+ async invoke(input) {
14
+ const ctx = {
15
+ signal: controller.signal,
16
+ fs: workspace.fs,
17
+ ...(opts?.middleware ? { middleware: opts.middleware } : {}),
18
+ };
19
+ return await tool(input, ctx);
20
+ },
21
+ workspace,
22
+ close,
23
+ [Symbol.asyncDispose]: close,
24
+ };
25
+ }