@lunora/testing 1.0.0-alpha.9 → 1.0.0-alpha.90

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/dist/index.d.mts CHANGED
@@ -1,17 +1,174 @@
1
- import { ScheduledJob, RegisteredAction, RegisteredMutation, RegisteredQuery, ArgsValidator, InferArgs, ActionCtx, MutationCtx, QueryCtx, TableDefinition, Schema } from '@lunora/server';
1
+ import { AgentMessageRow, AgentRunInput, AgentGenerateResult, AgentRunResult, AgentDefinition } from '@lunora/agent';
2
+ import { ScheduledJob, RegisteredAction, RegisteredMutation, RegisteredQuery, ArgsValidator, InferArgs, ActionCtx, MutationCtx, QueryCtx, LogFields, TableDefinition, Schema } from '@lunora/server';
2
3
  export { type InboxOptions, type WaitForMailOptions, extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
4
+ /** A thread message as the in-memory runtime stores it (superset of {@link AgentMessageRow}). */
5
+ interface HarnessMessage extends AgentMessageRow {
6
+ messageKey: string;
7
+ threadKey: string;
8
+ }
9
+ /** A thread record as the in-memory runtime stores it. */
10
+ interface HarnessThread {
11
+ agent: string;
12
+ error?: string;
13
+ instanceId?: string;
14
+ key: string;
15
+ messageCount: number;
16
+ owner?: string;
17
+ status: string;
18
+ title?: string;
19
+ usage?: unknown;
20
+ }
21
+ /** One recorded function dispatch the loop made through `ctx.run`. */
22
+ interface HarnessDispatch {
23
+ args: Record<string, unknown> | undefined;
24
+ path: string;
25
+ }
26
+ /** Extra function-dispatch handler layered over the built-in `agents:*` runtime double. */
27
+ type HarnessFunction = (args?: Record<string, unknown>) => unknown;
28
+ interface AgentHarnessOptions {
29
+ /**
30
+ * Worker `env` bindings visible to tool `execute` and a dynamic
31
+ * `instructions` thunk. Default `{ LUNORA_TEST: true }`.
32
+ */
33
+ env?: Record<string, unknown>;
34
+ /**
35
+ * The agent's `lunora/agents.ts` export name — used for thread attribution
36
+ * and to derive the child-agent `AGENT_*` bindings a sub-agent tool targets.
37
+ * Default `"agent"`.
38
+ */
39
+ exportName?: string;
40
+ /**
41
+ * Stub the app functions a tool's `execute` (or the agent's `memory.source`)
42
+ * dispatches through `ctx.run`, keyed by function path (`"weather:lookup"`).
43
+ * A handler layered here shadows the built-in `agents:*` runtime double for
44
+ * that path. An unstubbed non-`agents:*` dispatch throws, so a missing stub
45
+ * fails loudly rather than silently returning `undefined`.
46
+ */
47
+ functions?: Record<string, HarnessFunction>;
48
+ /**
49
+ * Scripted model decisions, one {@link AgentGenerateResult} per LLM turn —
50
+ * the default script for {@link AgentHarness.run}. A terminal turn has an
51
+ * empty `toolCalls`; a tool-calling turn lists the calls the loop then runs
52
+ * against the agent's own tools. Override per run via `run(..., { script })`.
53
+ */
54
+ script: AgentGenerateResult[];
55
+ }
56
+ /** Per-run overrides for {@link AgentHarness.run}. */
57
+ interface AgentRunOverrides {
58
+ /** The workflow instance id for this run (default: an auto-incrementing `wf-N`). */
59
+ instanceId?: string;
60
+ /** The model script for THIS run, replacing the harness default. */
61
+ script?: AgentGenerateResult[];
62
+ }
63
+ interface AgentHarness {
64
+ /**
65
+ * Every function dispatch the loop has made through `ctx.run`, in order —
66
+ * both the `agents:*` runtime calls and the tool/memory app dispatches. Use
67
+ * it to assert a tool called the function you expected with the args you
68
+ * expected.
69
+ */
70
+ readonly dispatches: ReadonlyArray<HarnessDispatch>;
71
+ /** The persisted messages of a thread, ordered by `seq`. */
72
+ messages: (threadKey: string) => AgentMessageRow[];
73
+ /**
74
+ * Drive one durable agent run to completion against the in-memory journal +
75
+ * runtime double, returning the loop's {@link AgentRunResult}. Reuse the same
76
+ * `threadKey` across calls to continue a conversation (the persisted history
77
+ * carries over); each call runs under a fresh instance id, modelling a
78
+ * distinct workflow instance.
79
+ */
80
+ run: (params: AgentRunInput, overrides?: AgentRunOverrides) => Promise<AgentRunResult>;
81
+ /** The stored thread record (status, error, instanceId, usage, …), or `undefined` before its first run. */
82
+ thread: (threadKey: string) => HarnessThread | undefined;
83
+ }
84
+ /**
85
+ * A unit-test harness for a `defineAgent` tool-loop that runs WITHOUT a real
86
+ * model or network. It drives {@link runAgentLoop} over the agent's own
87
+ * `AgentGenerate` seam — the model is a script of per-turn decisions, tool calls
88
+ * run the agent's real `execute` functions inside an in-memory durable-step
89
+ * journal, and thread/message persistence goes through an in-memory `agents:*`
90
+ * runtime double. Mock any function a tool (or the agent's memory) dispatches
91
+ * through the `functions` option.
92
+ *
93
+ * ```ts
94
+ * const harness = agentHarness(support, {
95
+ * script: [toolCallTurn("c1", "lookup", { id: "o_1" }), finalTurn("It shipped.")],
96
+ * functions: { "orders:lookup": () => ({ status: "shipped" }) },
97
+ * });
98
+ * const result = await harness.run({ input: "where is my order?", threadKey: "t1" });
99
+ * expect(result.text).toBe("It shipped.");
100
+ * expect(harness.messages("t1").at(-1)?.content).toBe("It shipped.");
101
+ * ```
102
+ *
103
+ * The harness deliberately does NOT model the concurrency guard or a
104
+ * human-in-the-loop approval pause — it is for exercising an agent's tools and
105
+ * turn logic, not the durable orchestration itself (that is covered inside
106
+ * `@lunora/agent`).
107
+ */
108
+ declare const agentHarness: (agent: AgentDefinition, options: AgentHarnessOptions) => AgentHarness;
109
+ /** A terminal (final-answer) turn — no tool calls. */
110
+ declare const finalTurn: (text: string, extra?: Partial<AgentGenerateResult>) => AgentGenerateResult;
111
+ /** A single-tool-call turn: the loop runs the named tool with `input`. */
112
+ declare const toolCallTurn: (id: string, name: string, input: unknown, text?: string) => AgentGenerateResult;
113
+ /** The primitive an eval attaches to a span: a numeric score or a string label. */
114
+ type EvaluationAttributeValue = number | string;
115
+ /**
116
+ * Structural slice of the post-hoc span handle `ctx.trace` hands its body (see the
117
+ * server `SpanHandle`) — enough to attach an eval's attributes. Declared here
118
+ * rather than imported so `@lunora/testing` takes no dependency on `@lunora/server`
119
+ * or `@lunora/do`; the real handle is assignable to it.
120
+ */
121
+ interface EvaluationSpanHandle {
122
+ setAttributes: (fields: Record<string, EvaluationAttributeValue>) => void;
123
+ }
124
+ /** One eval verdict to emit. */
125
+ interface RecordEvaluationInput {
126
+ /**
127
+ * Optional categorical label (e.g. `"pass"` / `"fail"` / a rubric bucket),
128
+ * emitted as the `.label` attribute. Omitted → no label attribute.
129
+ */
130
+ label?: string;
131
+ /**
132
+ * The scorer/evaluation name — becomes the key's name segment. Any character
133
+ * outside `[A-Za-z0-9._-]` is replaced with `_` so a scorer name carrying a
134
+ * colon (e.g. `"contains:shipped"`) still yields a well-formed attribute key.
135
+ */
136
+ name: string;
137
+ /** The numeric score (typically `[0, 1]`), emitted as the `.score` attribute. */
138
+ score: number;
139
+ /**
140
+ * Optional generation span to attach the attributes to — the post-hoc
141
+ * `SpanHandle` a `ctx.trace` body receives. Omitted → nothing is attached and
142
+ * the caller uses the returned bag for a standalone event/metric.
143
+ */
144
+ span?: EvaluationSpanHandle;
145
+ }
146
+ /**
147
+ * Build the `gen_ai.evaluation.NAME.*` attribute bag for one eval verdict — the
148
+ * `.score` (number) always, the `.label` (string) when a label is given. Exported
149
+ * so a caller can emit the score as a standalone event/metric without a span.
150
+ */
151
+ declare const evaluationAttributes: (input: Pick<RecordEvaluationInput, "label" | "name" | "score">) => Record<string, EvaluationAttributeValue>;
152
+ /**
153
+ * Emit one eval verdict as `gen_ai.evaluation.NAME.*` attributes. Attaches them to
154
+ * `input.span` when supplied (the post-hoc generation-span handle), and always
155
+ * returns the attribute bag so a span-less caller can ship it as an eval
156
+ * event/metric. Privacy-safe: only the name, score, and optional label are
157
+ * emitted — never the graded prompt or output.
158
+ */
159
+ declare const recordEvaluation: (input: RecordEvaluationInput) => Record<string, EvaluationAttributeValue>;
3
160
  /** A pending job entry in the fake scheduler queue. */
4
161
  interface FakeScheduledJob extends ScheduledJob {
5
162
  /** The args the job was scheduled with. */
6
163
  args: Record<string, unknown>;
7
164
  }
8
165
  /**
9
- * A single scheduled-job failure captured during an `advance()` / `runPending()`
10
- * sweep. Production's scheduler isolates per-job failures (one bad job does not
11
- * abort the rest), so the fake scheduler does the same — but, being a test
12
- * harness, it never swallows the error: every failure is recorded here so tests
13
- * can still assert on it.
14
- */
166
+ * A single scheduled-job failure captured during an `advance()` / `runPending()`
167
+ * sweep. Production's scheduler isolates per-job failures (one bad job does not
168
+ * abort the rest), so the fake scheduler does the same — but, being a test
169
+ * harness, it never swallows the error: every failure is recorded here so tests
170
+ * can still assert on it.
171
+ */
15
172
  interface ScheduledJobFailure {
16
173
  /** The args the job was dispatched with. */
17
174
  args: Record<string, unknown>;
@@ -23,94 +180,85 @@ interface ScheduledJobFailure {
23
180
  id: string;
24
181
  }
25
182
  /**
26
- * Controls exposed on the test harness for the fake in-memory scheduler.
27
- * Access via `harness.scheduler`.
28
- */
183
+ * Controls exposed on the test harness for the fake in-memory scheduler.
184
+ * Access via `harness.scheduler`.
185
+ */
29
186
  interface FakeSchedulerControls {
30
187
  /**
31
- * Advance the virtual clock by `ms` milliseconds, executing all jobs whose
32
- * `scheduledFor` timestamp is now at or before the new virtual "now". Jobs
33
- * are dispatched in `scheduledFor` order (oldest first). Newly queued jobs
34
- * (scheduled by an executed job during the advance) are NOT re-evaluated in
35
- * the same advance call — callers should advance again if needed.
36
- *
37
- * Per-job failures are isolated (matching production): a job that throws does
38
- * NOT prevent the remaining due jobs from running. After every due job has
39
- * run, the failures are surfaced — they are recorded on
40
- * {@link FakeSchedulerControls.failures} and, by default, re-thrown so a test
41
- * still sees the error. A single failure is re-thrown verbatim; multiple
42
- * failures are aggregated into an `AggregateError`. Pass
43
- * `{ throwOnError: false }` to suppress the re-throw and inspect
44
- * {@link FakeSchedulerControls.failures} (and the returned count) instead.
45
- *
46
- * Returns the number of jobs that were executed (including failed ones).
47
- */
188
+ * Advance the virtual clock by `ms` milliseconds, executing all jobs whose
189
+ * `scheduledFor` timestamp is now at or before the new virtual "now". Jobs
190
+ * are dispatched in `scheduledFor` order (oldest first). Newly queued jobs
191
+ * (scheduled by an executed job during the advance) are NOT re-evaluated in
192
+ * the same advance call — callers should advance again if needed.
193
+ *
194
+ * Per-job failures are isolated (matching production): a job that throws does
195
+ * NOT prevent the remaining due jobs from running. After every due job has
196
+ * run, the failures are surfaced — they are recorded on
197
+ * {@link FakeSchedulerControls.failures} and, by default, re-thrown so a test
198
+ * still sees the error. A single failure is re-thrown verbatim; multiple
199
+ * failures are aggregated into an `AggregateError`. Pass
200
+ * `{ throwOnError: false }` to suppress the re-throw and inspect
201
+ * {@link FakeSchedulerControls.failures} (and the returned count) instead.
202
+ *
203
+ * Returns the number of jobs that were executed (including failed ones).
204
+ */
48
205
  advance: (ms: number, options?: SweepOptions) => Promise<number>;
49
206
  /**
50
- * All scheduled-job failures captured so far, in execution order, across
51
- * every `advance()` / `runPending()` call on this harness. Always available,
52
- * even when `throwOnError: false` suppressed the re-throw. The list is a
53
- * snapshot — mutating it does not affect the scheduler.
54
- */
207
+ * All scheduled-job failures captured so far, in execution order, across
208
+ * every `advance()` / `runPending()` call on this harness. Always available,
209
+ * even when `throwOnError: false` suppressed the re-throw. The list is a
210
+ * snapshot — mutating it does not affect the scheduler.
211
+ */
55
212
  failures: () => ScheduledJobFailure[];
56
213
  /**
57
- * List all pending jobs (those not yet executed or cancelled) in the order
58
- * they were enqueued.
59
- */
214
+ * List all pending jobs (those not yet executed or cancelled) in the order
215
+ * they were enqueued.
216
+ */
60
217
  list: () => FakeScheduledJob[];
61
218
  /**
62
- * Execute all currently pending jobs regardless of their `scheduledFor`
63
- * time. Equivalent to advancing to `Infinity`. Returns the number of jobs
64
- * executed (including failed ones).
65
- *
66
- * Failure isolation and surfacing match {@link FakeSchedulerControls.advance}:
67
- * one failing job does not abort the rest, and failures are recorded on
68
- * {@link FakeSchedulerControls.failures} and re-thrown unless
69
- * `{ throwOnError: false }` is passed.
70
- */
219
+ * Execute all currently pending jobs regardless of their `scheduledFor`
220
+ * time. Equivalent to advancing to `Infinity`. Returns the number of jobs
221
+ * executed (including failed ones).
222
+ *
223
+ * Failure isolation and surfacing match {@link FakeSchedulerControls.advance}:
224
+ * one failing job does not abort the rest, and failures are recorded on
225
+ * {@link FakeSchedulerControls.failures} and re-thrown unless
226
+ * `{ throwOnError: false }` is passed.
227
+ */
71
228
  runPending: (options?: SweepOptions) => Promise<number>;
72
229
  }
73
230
  /** Options controlling how an `advance()` / `runPending()` sweep surfaces failures. */
74
231
  interface SweepOptions {
75
232
  /**
76
- * When `true` (the default), failures encountered during the sweep are
77
- * re-thrown after every due job has run — a single failure verbatim, multiple
78
- * as an `AggregateError`. When `false`, the sweep resolves normally and
79
- * failures are observable only via {@link FakeSchedulerControls.failures}.
80
- */
233
+ * When `true` (the default), failures encountered during the sweep are
234
+ * re-thrown after every due job has run — a single failure verbatim, multiple
235
+ * as an `AggregateError`. When `false`, the sweep resolves normally and
236
+ * failures are observable only via {@link FakeSchedulerControls.failures}.
237
+ */
81
238
  throwOnError?: boolean;
82
239
  }
83
240
  /**
84
- * Top-level dispatch for a scheduled job — wired by the harness. Unlike the
85
- * `runInternal` closure used by `ctx.run*` composition (which rides whatever
86
- * transaction span is already open), a scheduled job is a fresh top-level entry:
87
- * production dispatches it back to the Worker as its own RPC, so a scheduled
88
- * `mutation` runs inside its own BEGIN/COMMIT span and notifies subscription
89
- * listeners on success. The harness supplies a callback that reproduces those
90
- * semantics (mutations wrapped + notified; actions run unwrapped).
91
- */
92
- /**
93
- * The schema value produced by `@lunora/server`'s `defineSchema`. Accepted
94
- * structurally; internally it is handed to `@lunora/do`'s `runShardMigrations` /
95
- * `createShardCtxDb`, whose `SchemaLike` is the same shape declared in the DO
96
- * package — the two are structurally compatible at runtime (only their
97
- * independently-declared trigger nesting drifts at the type level), so the
98
- * boundary cast below is sound.
99
- */
241
+ * The schema value produced by `@lunora/server`'s `defineSchema`. Accepted
242
+ * structurally; internally it is handed to `@lunora/do`'s `runShardMigrations` /
243
+ * `createShardCtxDb`, whose `SchemaLike` is the same shape declared in the DO
244
+ * package the two are structurally compatible at runtime (only their
245
+ * independently-declared trigger nesting drifts at the type level), so the
246
+ * boundary cast below is sound.
247
+ */
100
248
  type TestSchema = Schema<Record<string, TableDefinition>>;
101
249
  /**
102
- * A user-supplied identity, surfaced to handlers via `ctx.auth`. `userId` is the
103
- * subject the handler reads from `ctx.auth.userId`; any additional fields are
104
- * returned verbatim from `ctx.auth.getIdentity()` (mirroring a decoded JWT).
105
- */
250
+ * A user-supplied identity, surfaced to handlers via `ctx.auth`. `userId` is the
251
+ * subject the handler reads from `ctx.auth.userId`; any additional fields are
252
+ * returned verbatim from `ctx.auth.getIdentity()` (mirroring a decoded JWT).
253
+ */
106
254
  interface TestIdentity extends Record<string, unknown> {
107
255
  userId?: null | string;
108
256
  }
109
257
  /**
110
- * An async iterable/iterator returned by {@link TestHarness.subscribe}.
111
- * Guarantees `return()` is always defined (unlike the optional `AsyncIterator.return`),
112
- * so callers can always unsubscribe without a `?.` guard.
113
- */
258
+ * An async iterable/iterator returned by {@link TestHarness.subscribe}.
259
+ * Guarantees `return()` is always defined (unlike the optional `AsyncIterator.return`),
260
+ * so callers can always unsubscribe without a `?.` guard.
261
+ */
114
262
  interface TestSubscription<R> extends AsyncIterable<R> {
115
263
  next: () => Promise<IteratorResult<R, R>>;
116
264
  return: () => Promise<IteratorResult<R, R>>;
@@ -120,75 +268,117 @@ type InlineQueryFunction<R> = (context: QueryCtx) => Promise<R> | R;
120
268
  type InlineMutationFunction<R> = (context: MutationCtx) => Promise<R> | R;
121
269
  type InlineActionFunction<R> = (context: ActionCtx) => Promise<R> | R;
122
270
  /**
123
- * A map from function path strings (e.g. `"messages:send"`) to their
124
- * registered function objects. Used by the fake scheduler to resolve
125
- * `ctx.scheduler.runAfter(delay, "messages:send", args)` → handler invocation.
126
- *
127
- * Only mutations and actions can be scheduled in production; queries passed
128
- * here will be accepted but produce a console.warn at dispatch time.
129
- *
130
- * The value type uses `any` because `RegisteredFunction` is contravariant in its
131
- * args type parameter — a `RegisteredMutation` with concrete args is not assignable
132
- * to `RegisteredMutation` with `ArgsValidator` at the type level even though at
133
- * runtime it is sound (the fake scheduler passes `Record&lt;string, unknown>` to
134
- * `handler` and ignores the return value).
135
- */
271
+ * A map from function path strings (e.g. `"messages:send"`) to their
272
+ * registered function objects. Used by the fake scheduler to resolve
273
+ * `ctx.scheduler.runAfter(delay, "messages:send", args)` → handler invocation.
274
+ *
275
+ * Only mutations and actions can be scheduled in production; queries passed
276
+ * here will be accepted but produce a console.warn at dispatch time.
277
+ *
278
+ * The value type uses `any` because `RegisteredFunction` is contravariant in its
279
+ * args type parameter — a `RegisteredMutation` with concrete args is not assignable
280
+ * to `RegisteredMutation` with `ArgsValidator` at the type level even though at
281
+ * runtime it is sound (the fake scheduler passes `Record&lt;string, unknown>` to
282
+ * `handler` and ignores the return value).
283
+ */
136
284
  type FunctionRegistry = Record<string, RegisteredAction<any, any> | RegisteredMutation<any, any> | RegisteredQuery<any, any>>;
137
285
  /**
138
- * Options accepted by {@link lunoraTest}.
139
- *
140
- * All options are optional — `lunoraTest(schema)` preserves v1 behaviour with
141
- * clearly-throwing stubs for unsupported surfaces.
142
- */
286
+ * Options accepted by {@link lunoraTest}.
287
+ *
288
+ * All options are optional — `lunoraTest(schema)` preserves v1 behaviour with
289
+ * clearly-throwing stubs for unsupported surfaces.
290
+ */
143
291
  interface LunoraTestOptions {
144
292
  /**
145
- * Injectable `fetch` implementation for action contexts. When provided,
146
- * `ctx.fetch` in every `action` (and `withIdentity` views) resolves to this
147
- * function rather than throwing the "not available in v1" stub.
148
- *
149
- * Pass `vi.fn()` or any `typeof globalThis.fetch` compatible implementation.
150
- * @example
151
- * ```ts
152
- * const fakeFetch = vi.fn().mockResolvedValue(Response.json({ ok: true }));
153
- * const t = lunoraTest(schema, { fetch: fakeFetch });
154
- * ```
155
- */
293
+ * Enforce the secure-by-default RLS guard on the writer registered
294
+ * procedures dispatch through (`query`/`mutation`/`action`, via
295
+ * `reference.handler`) the same `enforceRls: true` production's generated
296
+ * `buildCtx` always passes. Under a `.rls("required")` schema, a procedure
297
+ * that touches a known, non-`.public()` table without `.use(rls(...))` in
298
+ * its chain rejects with `RlsRequiredError`, exactly as it would on first
299
+ * dispatch in production. Defaults to `true` so a green suite means the
300
+ * deploy is RLS-safe; the harness's other surfaces — `t.run` and any
301
+ * `@lunora/seed` helper built on it stay on the trusted, UNGUARDED writer
302
+ * regardless of this flag (mirroring production's admin/migration system
303
+ * paths).
304
+ *
305
+ * Set to `false` to opt back into the pre-guard permissive behaviour (every
306
+ * `lunoraTest` release before this option existed): every procedure's
307
+ * `ctx.db` goes unguarded even under a `.rls("required")` schema. This
308
+ * forfeits the "a passing suite means the deploy is safe" guarantee — a
309
+ * procedure that forgot `.use(rls(...))` will pass in tests and throw
310
+ * `RlsRequiredError` on its first production request. No effect when the
311
+ * schema does not declare `.rls("required")` (the guard is a no-op there
312
+ * either way).
313
+ * @default true
314
+ * @example
315
+ * ```ts
316
+ * // Restores the old permissive behavior for a suite not yet migrated.
317
+ * const t = lunoraTest(schema, { enforceRls: false });
318
+ * ```
319
+ */
320
+ enforceRls?: boolean;
321
+ /**
322
+ * Injectable `ctx.env` for every context (query / mutation / action). When
323
+ * provided, handlers that read `ctx.env.SOME_KEY` (the validated `defineEnv`
324
+ * surface) see this object. Left unset it stays `undefined` — matching the
325
+ * optional `ctx.env?` field, so graceful `ctx.env?.KEY` access still yields
326
+ * `undefined` rather than throwing. Not a throwing stub for exactly that
327
+ * reason: `env` is designed to be legitimately absent.
328
+ * @example
329
+ * ```ts
330
+ * const t = lunoraTest(schema, { env: { STRIPE_KEY: "sk_test_…" } });
331
+ * ```
332
+ */
333
+ env?: Record<string, unknown>;
334
+ /**
335
+ * Injectable `fetch` implementation for action contexts. When provided,
336
+ * `ctx.fetch` in every `action` (and `withIdentity` views) resolves to this
337
+ * function rather than throwing the "not available in v1" stub.
338
+ *
339
+ * Pass `vi.fn()` or any `typeof globalThis.fetch` compatible implementation.
340
+ * @example
341
+ * ```ts
342
+ * const fakeFetch = vi.fn().mockResolvedValue(Response.json({ ok: true }));
343
+ * const t = lunoraTest(schema, { fetch: fakeFetch });
344
+ * ```
345
+ */
156
346
  fetch?: typeof globalThis.fetch;
157
347
  /**
158
- * Function registry for the fake in-memory scheduler. Maps a
159
- * `functionPath` string (the value passed as the second argument to
160
- * `ctx.scheduler.runAfter` / `ctx.scheduler.runAt`) to the corresponding
161
- * registered function object.
162
- *
163
- * Only required if your handlers schedule work. Scheduled jobs for paths
164
- * NOT listed here produce a `console.warn` at dispatch time (matching prod
165
- * behaviour for unknown paths).
166
- * @example
167
- * ```ts
168
- * const t = lunoraTest(schema, {
169
- * functions: { "messages:send": sendMutation },
170
- * });
171
- * ```
172
- */
348
+ * Function registry for the fake in-memory scheduler. Maps a
349
+ * `functionPath` string (the value passed as the second argument to
350
+ * `ctx.scheduler.runAfter` / `ctx.scheduler.runAt`) to the corresponding
351
+ * registered function object.
352
+ *
353
+ * Only required if your handlers schedule work. Scheduled jobs for paths
354
+ * NOT listed here produce a `console.warn` at dispatch time (matching prod
355
+ * behaviour for unknown paths).
356
+ * @example
357
+ * ```ts
358
+ * const t = lunoraTest(schema, {
359
+ * functions: { "messages:send": sendMutation },
360
+ * });
361
+ * ```
362
+ */
173
363
  functions?: FunctionRegistry;
174
364
  /**
175
- * Fixed value for `ctx.now` (epoch ms) in every context. Production captures
176
- * `Date.now()` once per execution; in tests a fixed `now` makes time-dependent
177
- * handlers deterministic. Defaults to the wall clock at harness creation.
178
- * @example
179
- * ```ts
180
- * const t = lunoraTest(schema, { now: 1_700_000_000_000 });
181
- * ```
182
- */
365
+ * Fixed value for `ctx.now` (epoch ms) in every context. Production captures
366
+ * `Date.now()` once per execution; in tests a fixed `now` makes time-dependent
367
+ * handlers deterministic. Defaults to the wall clock at harness creation.
368
+ * @example
369
+ * ```ts
370
+ * const t = lunoraTest(schema, { now: 1_700_000_000_000 });
371
+ * ```
372
+ */
183
373
  now?: number;
184
374
  }
185
375
  /**
186
- * The in-memory test harness returned by {@link lunoraTest}. Mirrors the first
187
- * five methods of Convex's `convexTest`: `query` / `mutation` / `action` / `run`
188
- * / `withIdentity`. All five share one in-memory `node:sqlite` backend, so a
189
- * write from one method is visible to a read from another (including across a
190
- * `withIdentity` scope).
191
- */
376
+ * The in-memory test harness returned by {@link lunoraTest}. Mirrors the first
377
+ * five methods of Convex's `convexTest`: `query` / `mutation` / `action` / `run`
378
+ * / `withIdentity`. All five share one in-memory `node:sqlite` backend, so a
379
+ * write from one method is visible to a read from another (including across a
380
+ * `withIdentity` scope).
381
+ */
192
382
  interface TestHarness {
193
383
  /** Run a registered `action` (or an inline `async (context) => …`) against the harness. */
194
384
  action: {
@@ -207,69 +397,189 @@ interface TestHarness {
207
397
  <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>): Promise<R>;
208
398
  <R>(inline: InlineQueryFunction<R>): Promise<R>;
209
399
  };
210
- /** Direct db access at mutation-level (read + write), mirroring `convexTest`'s `run`. */
400
+ /**
401
+ * Direct db access at mutation-level (read + write), mirroring `convexTest`'s
402
+ * `run`. This is the harness's trusted escape hatch: `ctx.db` here is always
403
+ * the UNGUARDED writer, regardless of `options.enforceRls` or the schema's
404
+ * RLS mode — seeding/asserting against a protected table never trips the
405
+ * secure-by-default guard. A `ctx.runMutation`/`ctx.runQuery` call from
406
+ * inside the body still dispatches the target as a real registered
407
+ * procedure, so it is guarded exactly as `t.mutation`/`t.query` would guard
408
+ * it.
409
+ */
211
410
  run: <R>(function_: InlineMutationFunction<R>) => Promise<R>;
212
411
  /**
213
- * Controls for the fake in-memory scheduler. Always present; scheduler
214
- * jobs only execute when you call `advance(ms)` or `runPending()`.
215
- *
216
- * - `list()` — snapshot of all pending jobs (enqueue order).
217
- * - `advance(ms)` — tick the virtual clock forward by `ms` ms, executing every job
218
- * whose `scheduledFor` is now at or below virtual now.
219
- * - `runPending()` — execute all currently pending jobs regardless of their scheduled time.
220
- *
221
- * Scheduled jobs run through the same `runInternal` dispatch as
222
- * `ctx.runMutation`, so they share the harness SQLite database.
223
- */
412
+ * Controls for the fake in-memory scheduler. Always present; scheduler
413
+ * jobs only execute when you call `advance(ms)` or `runPending()`.
414
+ *
415
+ * - `list()` — snapshot of all pending jobs (enqueue order).
416
+ * - `advance(ms)` — tick the virtual clock forward by `ms` ms, executing every job
417
+ * whose `scheduledFor` is now at or below virtual now.
418
+ * - `runPending()` — execute all currently pending jobs regardless of their scheduled time.
419
+ *
420
+ * Scheduled jobs run through the same `runInternal` dispatch as
421
+ * `ctx.runMutation`, so they share the harness SQLite database.
422
+ */
224
423
  scheduler: FakeSchedulerControls;
225
424
  /**
226
- * Subscribe to a registered query (or inline query function) and receive
227
- * an async iterable of snapshots. The first value is emitted immediately
228
- * (the current query result). Subsequent values are emitted after each
229
- * `mutation` / `run` call on this harness completes.
230
- *
231
- * Subscriptions are table-agnostic — any mutation triggers a re-evaluation.
232
- * This matches the harness's single-writer model and keeps the implementation
233
- * free of DO machinery.
234
- * @example
235
- * ```ts
236
- * const sub = t.subscribe(list, {});
237
- * const first = await sub.next(); // current result
238
- * await t.mutation(send, { author: "ada", body: "hi" });
239
- * const second = await sub.next(); // updated result
240
- * await sub.return(); // unsubscribe
241
- * ```
242
- *
243
- * The iterable is lazy — it never buffers more than one pending result.
244
- * If you do not consume fast enough and multiple mutations fire, the next
245
- * `next()` call will reflect the most-recent state (intermediate snapshots
246
- * are coalesced).
247
- */
425
+ * Subscribe to a registered query (or inline query function) and receive
426
+ * an async iterable of snapshots. The first value is emitted immediately
427
+ * (the current query result). Subsequent values are emitted after each
428
+ * `mutation` / `run` call on this harness completes.
429
+ *
430
+ * Subscriptions are table-agnostic — any mutation triggers a re-evaluation.
431
+ * This matches the harness's single-writer model and keeps the implementation
432
+ * free of DO machinery.
433
+ * @example
434
+ * ```ts
435
+ * const sub = t.subscribe(list, {});
436
+ * const first = await sub.next(); // current result
437
+ * await t.mutation(send, { author: "ada", body: "hi" });
438
+ * const second = await sub.next(); // updated result
439
+ * await sub.return(); // unsubscribe
440
+ * ```
441
+ *
442
+ * The iterable is lazy — it never buffers more than one pending result.
443
+ * If you do not consume fast enough and multiple mutations fire, the next
444
+ * `next()` call will reflect the most-recent state (intermediate snapshots
445
+ * are coalesced).
446
+ */
248
447
  subscribe: {
249
448
  <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>): TestSubscription<R>;
250
449
  <R>(inline: InlineQueryFunction<R>): TestSubscription<R>;
251
450
  };
451
+ /**
452
+ * What handlers attached to `ctx.span` — the **wide event** — during this
453
+ * harness's runs, so a test can assert the instrumentation itself:
454
+ *
455
+ * ```ts
456
+ * await t.mutation(checkout, { items: 3 });
457
+ * expect(t.wideEvent().attributes["cart.items"]).toBe(3);
458
+ * ```
459
+ *
460
+ * Accumulates across calls on this view (it is not reset per run), mirroring
461
+ * the harness's single shared database. Shared with any `withIdentity` view.
462
+ */
463
+ wideEvent: () => RecordedWideEvent;
252
464
  /** Return a harness view that shares this harness's db but reports the given identity on `ctx.auth`. */
253
465
  withIdentity: (identity: TestIdentity) => TestHarness;
254
466
  }
467
+ /** What a handler attached to the dispatch's span (`ctx.span`) during a harness run. */
468
+ interface RecordedWideEvent {
469
+ /**
470
+ * Attributes accumulated across the run, merged in call order. Values are
471
+ * recorded AS PASSED (not coerced the way the real pipeline normalizes them),
472
+ * so a test asserts on what the handler meant rather than on the wire form.
473
+ */
474
+ attributes: LogFields;
475
+ /** Span events recorded via `ctx.span.addEvent` / `recordException`, in order. */
476
+ events: {
477
+ attributes?: LogFields;
478
+ name: string;
479
+ }[];
480
+ /** Links recorded via `ctx.span.addLink`, in order. */
481
+ links: {
482
+ spanId: string;
483
+ traceId: string;
484
+ }[];
485
+ }
255
486
  /**
256
- * Spin up an in-memory Lunora function harness for `schema`.
257
- *
258
- * `lunoraTest(schema)` runs the migrations against a fresh `node:sqlite`
259
- * database, builds the same `ctx.db` writer the real Durable Object builds (via
260
- * `@lunora/do`'s `createShardCtxDb`), and returns a harness whose `query` /
261
- * `mutation` / `action` / `run` execute a registered function's `handler`
262
- * directly no Durable Object, no `wrangler`, no network.
263
- *
264
- * **v1 surfaces now supported:**
265
- *
266
- * - `ctx.fetch` (actions): inject a custom `fetch` via `options.fetch`.
267
- * - `ctx.scheduler` (mutations + actions): fully functional fake with virtual clock;
268
- * control via `harness.scheduler.advance(ms)` / `runPending()` / `list()`.
269
- * - `harness.subscribe(query, args)`: async iterable that re-emits after mutations.
270
- *
271
- * **v1 stubs (still throwing):** `ctx.storage`, `ctx.vectors`, `ctx.workflows`.
272
- * These are clearly documented follow-ups.
273
- */
487
+ * Spin up an in-memory Lunora function harness for `schema`.
488
+ *
489
+ * `lunoraTest(schema)` runs the migrations against a fresh `node:sqlite`
490
+ * database, builds the same `ctx.db` writer the real Durable Object builds (via
491
+ * `@lunora/shard-engine`'s `createShardCtxDb`, with the same `enforceRls: true`
492
+ * production's generated `buildCtx` passes), and returns a harness whose
493
+ * `query` / `mutation` / `action` execute a registered function's `handler`
494
+ * directly — no Durable Object, no `wrangler`, no network. Under a
495
+ * `.rls("required")` schema a procedure missing `.use(rls(...))` therefore
496
+ * rejects here exactly as it would on its first production dispatch — see
497
+ * `LunoraTestOptions.enforceRls` to opt out, and `run`'s doc for the trusted
498
+ * escape hatch (always unguarded).
499
+ *
500
+ * **v1 surfaces now supported:**
501
+ *
502
+ * - `ctx.env` (all contexts): inject the validated env via `options.env`; unset it
503
+ * stays `undefined`, matching the optional `ctx.env?` field.
504
+ * - `ctx.fetch` (actions): inject a custom `fetch` via `options.fetch`.
505
+ * - `ctx.scheduler` (mutations + actions): fully functional fake with virtual clock;
506
+ * control via `harness.scheduler.advance(ms)` / `runPending()` / `list()`.
507
+ * - `harness.subscribe(query, args)`: async iterable that re-emits after mutations.
508
+ *
509
+ * **v1 stubs (still throwing):** `ctx.storage`, `ctx.vectors`, `ctx.workflows`.
510
+ * These are clearly documented follow-ups.
511
+ */
274
512
  declare const lunoraTest: (schema: TestSchema, options?: LunoraTestOptions) => TestHarness;
275
- export { type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type LunoraTestOptions, type ScheduledJobFailure, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, lunoraTest };
513
+ /** One sample handed to a {@link Scorer}. */
514
+ interface ScorerSample {
515
+ /** The reference/gold answer, when the scorer compares against one. */
516
+ expected?: string;
517
+ /** The prompt/input that produced the output (for LLM-judge context). */
518
+ input?: string;
519
+ /** Arbitrary per-sample metadata carried through. */
520
+ metadata?: Record<string, unknown>;
521
+ /** The model/agent output under test. */
522
+ output: string;
523
+ }
524
+ /** A scorer's verdict: a `[0, 1]` score and an optional human-readable reason. */
525
+ interface ScoreResult {
526
+ reason?: string;
527
+ score: number;
528
+ }
529
+ /** A named scorer — returns a `[0, 1]` score (or a {@link ScoreResult}) for a sample. */
530
+ interface Scorer {
531
+ name: string;
532
+ score: (sample: ScorerSample) => Promise<ScoreResult | number> | ScoreResult | number;
533
+ }
534
+ /** One dataset case: an input and its optional gold answer/metadata. */
535
+ interface EvalCase {
536
+ expected?: string;
537
+ input: string;
538
+ metadata?: Record<string, unknown>;
539
+ }
540
+ /** The per-case eval result: the produced output plus each scorer's verdict and the mean. */
541
+ interface EvalItemResult {
542
+ average: number;
543
+ input: string;
544
+ output: string;
545
+ scores: Record<string, ScoreResult>;
546
+ }
547
+ /** The whole eval run: per-case results plus the mean of their averages. */
548
+ interface EvalResult {
549
+ average: number;
550
+ items: EvalItemResult[];
551
+ }
552
+ /** Score 1 if the output contains `needle` (case-insensitive unless `caseSensitive`). */
553
+ declare const containsScorer: (needle: string, options?: {
554
+ caseSensitive?: boolean;
555
+ }) => Scorer;
556
+ /** Score 1 when `pattern` matches the output. */
557
+ declare const regexScorer: (pattern: RegExp, name?: string) => Scorer;
558
+ /** Score 1 when the trimmed output exactly equals the trimmed `expected`. */
559
+ declare const exactMatchScorer: () => Scorer;
560
+ /** Score the fraction of `keywords` (case-insensitive) present in the output. */
561
+ declare const keywordScorer: (keywords: ReadonlyArray<string>) => Scorer;
562
+ /**
563
+ * An LLM-as-judge scorer. `judge` is INJECTED — a `(prompt) => Promise&lt;string>`
564
+ * you wire to your model (e.g. via `ctx.ai` / `generateText`), so this module
565
+ * stays model-agnostic and the judge is mockable in tests. It returns the model's
566
+ * numeric verdict (`[0, 1]`) plus its reason.
567
+ */
568
+ declare const llmScorer: (options: {
569
+ criteria: string;
570
+ judge: (prompt: string) => Promise<string>;
571
+ name?: string;
572
+ }) => Scorer;
573
+ /** Run every scorer over one sample (concurrently) → each verdict keyed by name + their mean. */
574
+ declare const scoreSample: (sample: ScorerSample, scorers: ReadonlyArray<Scorer>) => Promise<{
575
+ average: number;
576
+ scores: Record<string, ScoreResult>;
577
+ }>;
578
+ /**
579
+ * Run a dataset through `produce` (e.g. an `agentHarness.run` wrapper returning
580
+ * the output text) and score each output with `scorers`. Cases run concurrently;
581
+ * give each its own thread/key inside `produce` if the producer is stateful.
582
+ * Returns per-case results plus the mean of their averages.
583
+ */
584
+ declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<string> | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
585
+ export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type EvalCase, type EvalItemResult, type EvalResult, type EvaluationAttributeValue, type EvaluationSpanHandle, type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type RecordEvaluationInput, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, evaluate, evaluationAttributes, exactMatchScorer, finalTurn, keywordScorer, llmScorer, lunoraTest, recordEvaluation, regexScorer, scoreSample, toolCallTurn };