@lunora/testing 1.0.0-alpha.26 → 1.0.0-alpha.260

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,292 @@
1
- import { ScheduledJob, RegisteredAction, RegisteredMutation, RegisteredQuery, ArgsValidator, InferArgs, ActionCtx, MutationCtx, QueryCtx, TableDefinition, Schema } from '@lunora/server';
2
- export { type InboxOptions, type WaitForMailOptions, extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
1
+ import { AgentMessageRow, AgentRunInput, AgentGenerateResult, AgentRunResult, AgentDefinition } from '@lunora/agent';
2
+ import { ArtifactsBindingLike, ArtifactsErrorCode, ArtifactsCommitMetadata, ArtifactsTreeEntry } from '@lunora/bindings/artifacts';
3
+ import { ScheduledJob, LogFields, RegisteredAction, RegisteredMutation, RegisteredQuery, ArgsValidator, InferArgs, ActionCtx, MutationCtx, QueryCtx, TableDefinition, Schema } from '@lunora/server';
4
+ import { NotifyDefinition } from '@lunora/notify';
5
+ import { ChannelType, ChannelPayloadMap } from '@visulima/notification';
6
+ import { QueueContentType } from '@lunora/queue';
7
+ export {
8
+ /**
9
+ * `@lunora/testing` — the user-facing toolkit for end-to-end testing a Lunora app.
10
+ *
11
+ * Today it surfaces the dev mail-catcher helpers from `@lunora/mail/testing`: in
12
+ * `lunora dev` every outbound email (sign-up verification, forgot-password, magic
13
+ * links) is captured into the studio's root-shard inbox instead of hitting a real
14
+ * provider, and these helpers read that inbox over the admin RPC so a Playwright
15
+ * test can drive "request reset → read the email → follow the link → set a new
16
+ * password" deterministically.
17
+ *
18
+ * This is the home for E2E fixtures to grow into — auth helpers and more — so
19
+ * tests import one package (`@lunora/testing`) rather than reaching into each
20
+ * sub-package's `/testing` entry.
21
+ */
22
+ type InboxOptions,
23
+ /**
24
+ * `@lunora/testing` — the user-facing toolkit for end-to-end testing a Lunora app.
25
+ *
26
+ * Today it surfaces the dev mail-catcher helpers from `@lunora/mail/testing`: in
27
+ * `lunora dev` every outbound email (sign-up verification, forgot-password, magic
28
+ * links) is captured into the studio's root-shard inbox instead of hitting a real
29
+ * provider, and these helpers read that inbox over the admin RPC so a Playwright
30
+ * test can drive "request reset → read the email → follow the link → set a new
31
+ * password" deterministically.
32
+ *
33
+ * This is the home for E2E fixtures to grow into — auth helpers and more — so
34
+ * tests import one package (`@lunora/testing`) rather than reaching into each
35
+ * sub-package's `/testing` entry.
36
+ */
37
+ type WaitForMailOptions, extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
38
+ /** A thread message as the in-memory runtime stores it (superset of {@link AgentMessageRow}). */
39
+ interface HarnessMessage extends AgentMessageRow {
40
+ messageKey: string;
41
+ threadKey: string;
42
+ }
43
+ /** A thread record as the in-memory runtime stores it. */
44
+ interface HarnessThread {
45
+ agent: string;
46
+ error?: string;
47
+ instanceId?: string;
48
+ key: string;
49
+ messageCount: number;
50
+ owner?: string;
51
+ status: string;
52
+ title?: string;
53
+ usage?: unknown;
54
+ }
55
+ /** One recorded function dispatch the loop made through `ctx.run`. */
56
+ interface HarnessDispatch {
57
+ args: Record<string, unknown> | undefined;
58
+ path: string;
59
+ }
60
+ /** Extra function-dispatch handler layered over the built-in `agents:*` runtime double. */
61
+ type HarnessFunction = (args?: Record<string, unknown>) => unknown;
62
+ interface AgentHarnessOptions {
63
+ /**
64
+ * Worker `env` bindings visible to tool `execute` and a dynamic
65
+ * `instructions` thunk. Default `{ LUNORA_TEST: true }`.
66
+ */
67
+ env?: Record<string, unknown>;
68
+ /**
69
+ * The agent's `lunora/agents.ts` export name — used for thread attribution
70
+ * and to derive the child-agent export keys a sub-agent tool targets.
71
+ * Default `"agent"`.
72
+ */
73
+ exportName?: string;
74
+ /**
75
+ * Stub the app functions a tool's `execute` (or the agent's `memory.source`)
76
+ * dispatches through `ctx.run`, keyed by function path (`"weather:lookup"`).
77
+ * A handler layered here shadows the built-in `agents:*` runtime double for
78
+ * that path. An unstubbed non-`agents:*` dispatch throws, so a missing stub
79
+ * fails loudly rather than silently returning `undefined`.
80
+ */
81
+ functions?: Record<string, HarnessFunction>;
82
+ /**
83
+ * Scripted model decisions, one {@link AgentGenerateResult} per LLM turn —
84
+ * the default script for {@link AgentHarness.run}. A terminal turn has an
85
+ * empty `toolCalls`; a tool-calling turn lists the calls the loop then runs
86
+ * against the agent's own tools. Override per run via `run(..., { script })`.
87
+ */
88
+ script: AgentGenerateResult[];
89
+ }
90
+ /** Per-run overrides for {@link AgentHarness.run}. */
91
+ interface AgentRunOverrides {
92
+ /** The workflow instance id for this run (default: an auto-incrementing `wf-N`). */
93
+ instanceId?: string;
94
+ /** The model script for THIS run, replacing the harness default. */
95
+ script?: AgentGenerateResult[];
96
+ }
97
+ interface AgentHarness {
98
+ /**
99
+ * Every function dispatch the loop has made through `ctx.run`, in order —
100
+ * both the `agents:*` runtime calls and the tool/memory app dispatches. Use
101
+ * it to assert a tool called the function you expected with the args you
102
+ * expected.
103
+ */
104
+ readonly dispatches: ReadonlyArray<HarnessDispatch>;
105
+ /** The persisted messages of a thread, ordered by `seq`. */
106
+ messages: (threadKey: string) => AgentMessageRow[];
107
+ /**
108
+ * Drive one durable agent run to completion against the in-memory journal +
109
+ * runtime double, returning the loop's {@link AgentRunResult}. Reuse the same
110
+ * `threadKey` across calls to continue a conversation (the persisted history
111
+ * carries over); each call runs under a fresh instance id, modelling a
112
+ * distinct workflow instance.
113
+ */
114
+ run: (params: AgentRunInput, overrides?: AgentRunOverrides) => Promise<AgentRunResult>;
115
+ /** The stored thread record (status, error, instanceId, usage, …), or `undefined` before its first run. */
116
+ thread: (threadKey: string) => HarnessThread | undefined;
117
+ }
118
+ /**
119
+ * A unit-test harness for a `defineAgent` tool-loop that runs WITHOUT a real
120
+ * model or network. It drives {@link runAgentLoop} over the agent's own
121
+ * `AgentGenerate` seam — the model is a script of per-turn decisions, tool calls
122
+ * run the agent's real `execute` functions inside an in-memory durable-step
123
+ * journal, and thread/message persistence goes through an in-memory `agents:*`
124
+ * runtime double. Mock any function a tool (or the agent's memory) dispatches
125
+ * through the `functions` option.
126
+ *
127
+ * ```ts
128
+ * const harness = agentHarness(support, {
129
+ * script: [toolCallTurn("c1", "lookup", { id: "o_1" }), finalTurn("It shipped.")],
130
+ * functions: { "orders:lookup": () => ({ status: "shipped" }) },
131
+ * });
132
+ * const result = await harness.run({ input: "where is my order?", threadKey: "t1" });
133
+ * expect(result.text).toBe("It shipped.");
134
+ * expect(harness.messages("t1").at(-1)?.content).toBe("It shipped.");
135
+ * ```
136
+ *
137
+ * The harness deliberately does NOT model the concurrency guard or a
138
+ * human-in-the-loop approval pause — it is for exercising an agent's tools and
139
+ * turn logic, not the durable orchestration itself (that is covered inside
140
+ * `@lunora/agent`).
141
+ */
142
+ declare const agentHarness: (agent: AgentDefinition, options: AgentHarnessOptions) => AgentHarness;
143
+ /** A terminal (final-answer) turn — no tool calls. */
144
+ declare const finalTurn: (text: string, extra?: Partial<AgentGenerateResult>) => AgentGenerateResult;
145
+ /** A single-tool-call turn: the loop runs the named tool with `input`. */
146
+ declare const toolCallTurn: (id: string, name: string, input: unknown, text?: string) => AgentGenerateResult;
147
+ /**
148
+ * Shared, bundler-inlined builder for the OpenTelemetry `gen_ai.evaluation.*`
149
+ * attributes an AI **evaluation** verdict (a scorer's `{name, score, label?}`)
150
+ * contributes to a **generation span**.
151
+ *
152
+ * This is the emit-time counterpart of the cloud OTLP decoder, which reads
153
+ * `gen_ai.evaluation.<name>.score` (number) and optional
154
+ * `gen_ai.evaluation.<name>.label` (string) attribute pairs back off a generation
155
+ * span (`EVALUATION_PREFIX = "gen_ai.evaluation."`). The framework emits exactly
156
+ * that pair here so a score rides the same trace as the generation it grades.
157
+ *
158
+ * It lives in `shared/` because more than one layer needs the identical wire
159
+ * format with no runtime dependency edge between them: `@lunora/do` builds it into
160
+ * a live `ctx.trace` span's post-hoc attributes (`SpanHandle.recordEvaluation`),
161
+ * and `@lunora/server` mirrors the handle shape structurally. `@lunora/testing`
162
+ * ships a parallel test-time helper (`recordEvaluation` / `evaluationAttributes`)
163
+ * that targets the same `gen_ai.evaluation.*` contract for span-less eval
164
+ * events/metrics. Keep this file genuinely zero-dependency so inlining stays sound.
165
+ */
166
+ /** The primitive an eval contributes to a span: a numeric score or a string label. */
167
+ type EvaluationAttributeValue = number | string;
168
+ /**
169
+ * Structural slice of the post-hoc span handle `ctx.trace` hands its body (see the
170
+ * server `SpanHandle`) — enough to attach an eval's attributes. Declared here
171
+ * rather than imported so `@lunora/testing` takes no dependency on `@lunora/server`
172
+ * or `@lunora/do`; the real handle is assignable to it.
173
+ */
174
+ interface EvaluationSpanHandle {
175
+ setAttributes: (fields: Record<string, EvaluationAttributeValue>) => void;
176
+ }
177
+ /**
178
+ * Structural slice of `ctx.metrics` — enough to record a score as a durable
179
+ * series. Declared here for the same reason as {@link EvaluationSpanHandle}: no
180
+ * dependency on `@lunora/server`, and the real handle is assignable.
181
+ */
182
+ interface EvaluationMetrics {
183
+ gauge: (name: string, value: number, attributes?: Record<string, unknown>) => void;
184
+ }
185
+ /** One eval verdict to emit. */
186
+ interface RecordEvaluationInput {
187
+ /**
188
+ * Optional categorical label (e.g. `"pass"` / `"fail"` / a rubric bucket),
189
+ * emitted as the `.label` attribute. Omitted → no label attribute.
190
+ */
191
+ label?: string;
192
+ /**
193
+ * Optional `ctx.metrics` handle. Passing it ALSO records the score as a
194
+ * `gen_ai.evaluation.<name>.score` gauge, which is what gives an eval a
195
+ * durable history: span attributes live in the shard's bounded in-memory
196
+ * ring and vanish on hibernation, while metrics are persisted in per-minute
197
+ * buckets and can be charted as a trend. Additive — the attributes are
198
+ * emitted either way.
199
+ */
200
+ metrics?: EvaluationMetrics;
201
+ /**
202
+ * The scorer/evaluation name — becomes the key's name segment. Any character
203
+ * outside `[A-Za-z0-9._-]` is replaced with `_` so a scorer name carrying a
204
+ * colon (e.g. `"contains:shipped"`) still yields a well-formed attribute key.
205
+ */
206
+ name: string;
207
+ /** The numeric score (typically `[0, 1]`), emitted as the `.score` attribute. */
208
+ score: number;
209
+ /**
210
+ * Optional generation span to attach the attributes to — the post-hoc
211
+ * `SpanHandle` a `ctx.trace` body receives. Omitted → nothing is attached and
212
+ * the caller uses the returned bag for a standalone event/metric.
213
+ */
214
+ span?: EvaluationSpanHandle;
215
+ }
216
+ /**
217
+ * Build the `gen_ai.evaluation.NAME.*` attribute bag for one eval verdict — the
218
+ * `.score` (number) always, the `.label` (string) when a label is given. Exported
219
+ * so a caller can emit the score as a standalone event/metric without a span.
220
+ *
221
+ * Delegates to the shared `shared/evaluation-attributes.ts` builder so
222
+ * `@lunora/testing`'s scorers and the runtime's own `recordEvaluation` emit the
223
+ * identical wire format — only the thrown error type differs, wrapped here as a
224
+ * `LunoraError` to match this package's public error contract.
225
+ */
226
+ declare const evaluationAttributes: (input: Pick<RecordEvaluationInput, "label" | "name" | "score">) => Record<string, EvaluationAttributeValue>;
227
+ /**
228
+ * Emit one eval verdict as `gen_ai.evaluation.NAME.*` attributes. Attaches them to
229
+ * `input.span` when supplied (the post-hoc generation-span handle), and always
230
+ * returns the attribute bag so a span-less caller can ship it as an eval
231
+ * event/metric. Privacy-safe: only the name, score, and optional label are
232
+ * emitted — never the graded prompt or output.
233
+ */
234
+ declare const recordEvaluation: (input: RecordEvaluationInput) => Record<string, EvaluationAttributeValue>;
235
+ /** Test controls returned alongside the fake binding. */
236
+ interface ArtifactsFake {
237
+ /** The fake `env.ARTIFACTS` binding. */
238
+ binding: ArtifactsBindingLike;
239
+ /** Make the next binding or repo call throw an `ArtifactsError` with `code` (e.g. `UPSTREAM_UNAVAILABLE`). */
240
+ failNext: (code: ArtifactsErrorCode) => void;
241
+ /** How many repo handles `get()` has handed out and how many were disposed — a leak shows as `opened > disposed`. */
242
+ readonly handles: {
243
+ disposed: number;
244
+ opened: number;
245
+ };
246
+ /** Stage a blob by object id. Returns the stored `Blob` (the exact instance `readBlob` hands back). */
247
+ putBlob: (repo: string, hash: string, content: Blob | string) => Blob;
248
+ /** Append a commit to the repo's history (newest first in `log()`), readable by `readCommit`. */
249
+ putCommit: (repo: string, commit: ArtifactsCommitMetadata) => void;
250
+ /** Stage a file at a ref, standing in for a push. Returns the stored `Blob` (the exact instance `readFile` hands back). */
251
+ putFile: (repo: string, file: {
252
+ content: Blob | string;
253
+ path: string;
254
+ ref: string;
255
+ type?: string;
256
+ }) => Blob;
257
+ /** Stage a tree by object id. */
258
+ putTree: (repo: string, hash: string, entries: ArtifactsTreeEntry[]) => void;
259
+ /** The repo names currently in the namespace. */
260
+ repoNames: () => string[];
261
+ }
262
+ /**
263
+ * Build an in-memory Artifacts binding plus seeding and inspection controls.
264
+ *
265
+ * ```ts
266
+ * const fake = createArtifactsFake({ namespace: "default" });
267
+ * const artifacts = createArtifacts({ binding: fake.binding });
268
+ * await artifacts.create("docs");
269
+ * fake.putFile("docs", { ref: "main", path: "README.md", content: "# hi" });
270
+ * ```
271
+ */
272
+ declare const createArtifactsFake: (options?: {
273
+ namespace?: string;
274
+ }) => ArtifactsFake;
3
275
  /** A pending job entry in the fake scheduler queue. */
4
276
  interface FakeScheduledJob extends ScheduledJob {
5
277
  /** The args the job was scheduled with. */
6
278
  args: Record<string, unknown>;
7
279
  }
8
280
  /**
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
- */
281
+ * A single scheduled-job failure that exhausted its retry budget, captured
282
+ * during an `advance()` / `runPending()` sweep. Mirrors `SchedulerDO`'s
283
+ * dead-letter park (`recordRetry()`, `packages/scheduler/src/scheduler-do.ts:875-909`):
284
+ * a job that fails while it still has retries left is silently re-enqueued
285
+ * with backoff and is NOT recorded here. Only once a job's `attempts` exceeds
286
+ * `@lunora/scheduler`'s `MAX_RETRY_ATTEMPTS` does it land here — being a test
287
+ * harness, the fake scheduler never swallows a terminal failure, so tests can
288
+ * still assert on it.
289
+ */
15
290
  interface ScheduledJobFailure {
16
291
  /** The args the job was dispatched with. */
17
292
  args: Record<string, unknown>;
@@ -23,94 +298,168 @@ interface ScheduledJobFailure {
23
298
  id: string;
24
299
  }
25
300
  /**
26
- * Controls exposed on the test harness for the fake in-memory scheduler.
27
- * Access via `harness.scheduler`.
28
- */
301
+ * Controls exposed on the test harness for the fake in-memory scheduler.
302
+ * Access via `harness.scheduler`.
303
+ */
29
304
  interface FakeSchedulerControls {
30
305
  /**
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
- */
306
+ * Advance the virtual clock by `ms` milliseconds, executing all jobs whose
307
+ * `scheduledFor` timestamp is now at or before the new virtual "now". Jobs
308
+ * are dispatched in `scheduledFor` order (oldest first). Newly queued jobs
309
+ * (scheduled by an executed job, or re-enqueued as a retry, during the
310
+ * advance) are NOT re-evaluated in the same advance call — callers should
311
+ * advance again if needed.
312
+ *
313
+ * Per-job failures are isolated (matching production): a job that throws does
314
+ * NOT prevent the remaining due jobs from running. A failure with retries
315
+ * left is silently re-enqueued with exponential backoff on the virtual clock
316
+ * — mirroring `SchedulerDO`'s default retry policy (`MAX_RETRY_ATTEMPTS`
317
+ * retries, `RETRY_BASE_DELAY_MS` base delay, doubling; both imported from
318
+ * `@lunora/scheduler`) — rather than surfaced. Advancing far enough to
319
+ * observe a terminal failure therefore costs the WHOLE backoff schedule
320
+ * (30s + 60s + 120s + 240s + 480s = 930s of virtual clock at today's
321
+ * defaults), not a single tick.
322
+ * Only once a job's retry budget is exhausted is the failure surfaced: it is
323
+ * recorded on {@link FakeSchedulerControls.failures} and, by default,
324
+ * re-thrown so a test still sees the error. A single such failure is
325
+ * re-thrown verbatim; multiple are aggregated into an `AggregateError`. Pass
326
+ * `{ throwOnError: false }` to suppress the re-throw and inspect
327
+ * {@link FakeSchedulerControls.failures} (and the returned count) instead.
328
+ *
329
+ * Returns the number of jobs dispatched this sweep, including ones that
330
+ * failed and were silently retried, and ones that failed terminally.
331
+ */
48
332
  advance: (ms: number, options?: SweepOptions) => Promise<number>;
49
333
  /**
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
- */
334
+ * All scheduled-job failures that exhausted their retry budget, in
335
+ * execution order, across every `advance()` / `runPending()` call on this
336
+ * harness. A failure with retries remaining is NOT recorded here — see
337
+ * {@link ScheduledJobFailure}. Always available, even when
338
+ * `throwOnError: false` suppressed the re-throw. The list is a snapshot —
339
+ * mutating it does not affect the scheduler.
340
+ */
55
341
  failures: () => ScheduledJobFailure[];
56
342
  /**
57
- * List all pending jobs (those not yet executed or cancelled) in the order
58
- * they were enqueued.
59
- */
343
+ * List all pending jobs (those not yet executed or cancelled) in the order
344
+ * they were enqueued. A job currently waiting out its retry backoff is
345
+ * still pending (visible here with its `attempts` count incremented and
346
+ * `scheduledFor` pushed out) until its budget is exhausted.
347
+ */
60
348
  list: () => FakeScheduledJob[];
61
349
  /**
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
- */
350
+ * Execute all currently pending jobs regardless of their `scheduledFor`
351
+ * time. Equivalent to advancing to `Infinity`. Returns the number of jobs
352
+ * dispatched this sweep (including failed ones, retried or terminal).
353
+ *
354
+ * Failure isolation, retry, and surfacing match
355
+ * {@link FakeSchedulerControls.advance}: one failing job does not abort the
356
+ * rest, a failure under the retry budget is silently re-enqueued rather
357
+ * than surfaced, and only a terminal failure is recorded on
358
+ * {@link FakeSchedulerControls.failures} and re-thrown unless
359
+ * `{ throwOnError: false }` is passed.
360
+ */
71
361
  runPending: (options?: SweepOptions) => Promise<number>;
72
362
  }
73
363
  /** Options controlling how an `advance()` / `runPending()` sweep surfaces failures. */
74
364
  interface SweepOptions {
75
365
  /**
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
- */
366
+ * When `true` (the default), failures encountered during the sweep are
367
+ * re-thrown after every due job has run — a single failure verbatim, multiple
368
+ * as an `AggregateError`. When `false`, the sweep resolves normally and
369
+ * failures are observable only via {@link FakeSchedulerControls.failures}.
370
+ */
81
371
  throwOnError?: boolean;
82
372
  }
373
+ /** What a handler attached to the dispatch's span (`ctx.span`) during a harness run. */
374
+ interface RecordedWideEvent {
375
+ /**
376
+ * Attributes accumulated across the run, merged in call order. Values are
377
+ * recorded AS PASSED (not coerced the way the real pipeline normalizes them),
378
+ * so a test asserts on what the handler meant rather than on the wire form.
379
+ */
380
+ attributes: LogFields;
381
+ /** Span events recorded via `ctx.span.addEvent` / `recordException`, in order. */
382
+ events: {
383
+ attributes?: LogFields;
384
+ name: string;
385
+ }[];
386
+ /** Links recorded via `ctx.span.addLink`, in order. */
387
+ links: {
388
+ spanId: string;
389
+ traceId: string;
390
+ }[];
391
+ }
392
+ /** The channels `ctx.notify` can deliver on. */
393
+ type NotifyChannel = Extract<ChannelType, "chat" | "inapp" | "push" | "webhook">;
83
394
  /**
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
- */
395
+ * One delivery a handler made through `ctx.notify` / `ctx.push`. For push,
396
+ * `payload.to` is the device target: the FCM token, or for Web Push the
397
+ * JSON-stringified subscription (keys included).
398
+ */
399
+ type SentNotification = { [C in NotifyChannel]: {
400
+ channel: C;
401
+ payload: ChannelPayloadMap[C];
402
+ }; }[NotifyChannel];
403
+ /** Inspect what handlers delivered through `ctx.notify` / `ctx.push`. */
404
+ interface FakeNotifyControls {
405
+ /** Forget every recorded delivery (registered subscriptions are kept). */
406
+ clear: () => void;
407
+ /** Recorded deliveries in send order, optionally only those on `channel`. Throws when `options.notify` was not passed. */
408
+ sent: (channel?: NotifyChannel) => SentNotification[];
409
+ }
410
+ /** One message a handler enqueued through `ctx.queues.<name>`, as the consumer decodes it. */
411
+ interface SentQueueMessage {
412
+ body: unknown;
413
+ contentType?: QueueContentType;
414
+ delaySeconds?: number;
415
+ /** The `lunora/queues.ts` export name the message was sent through. */
416
+ queue: string;
417
+ }
418
+ /**
419
+ * Inspect what handlers enqueued. Production sends are not transactional — a
420
+ * mutation that throws after `ctx.queues.x.send(...)` has still sent it — so the
421
+ * record keeps those too. Nothing is consumed: a test asserts on the messages and
422
+ * drives the consumer itself if it wants to.
423
+ */
424
+ interface FakeQueueControls {
425
+ /** Forget every recorded message. */
426
+ clear: () => void;
427
+ /** Recorded messages in send order, optionally only those sent to `queue`; an undeclared `queue` throws. */
428
+ sent: (queue?: string) => SentQueueMessage[];
429
+ }
430
+ /** One message a handler published through `ctx.topics.<name>`, as a subscriber decodes it. */
431
+ interface PublishedTopicMessage {
432
+ body: unknown;
433
+ contentType?: QueueContentType;
434
+ delaySeconds?: number;
435
+ /** The `lunora/queues.ts` topic export name the message was published to. */
436
+ topic: string;
437
+ }
92
438
  /**
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
- */
439
+ * Inspect what handlers published. Like queue sends, a publish is not
440
+ * transactional, so one made by a mutation that then threw stays recorded.
441
+ */
442
+ interface FakeTopicControls {
443
+ /** Forget every recorded message. */
444
+ clear: () => void;
445
+ /** Recorded messages in publish order, optionally only those published to `topic`; an undeclared `topic` throws. */
446
+ published: (topic?: string) => PublishedTopicMessage[];
447
+ }
448
+ /** The schema value produced by `@lunora/server`'s `defineSchema`. */
100
449
  type TestSchema = Schema<Record<string, TableDefinition>>;
101
450
  /**
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
- */
451
+ * A user-supplied identity, surfaced to handlers via `ctx.auth`. `userId` is the
452
+ * subject the handler reads from `ctx.auth.userId`; any additional fields are
453
+ * returned verbatim from `ctx.auth.getIdentity()` (mirroring a decoded JWT).
454
+ */
106
455
  interface TestIdentity extends Record<string, unknown> {
107
456
  userId?: null | string;
108
457
  }
109
458
  /**
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
- */
459
+ * An async iterable/iterator returned by {@link TestHarness.subscribe}.
460
+ * Guarantees `return()` is always defined (unlike the optional `AsyncIterator.return`),
461
+ * so callers can always unsubscribe without a `?.` guard.
462
+ */
114
463
  interface TestSubscription<R> extends AsyncIterable<R> {
115
464
  next: () => Promise<IteratorResult<R, R>>;
116
465
  return: () => Promise<IteratorResult<R, R>>;
@@ -120,75 +469,187 @@ type InlineQueryFunction<R> = (context: QueryCtx) => Promise<R> | R;
120
469
  type InlineMutationFunction<R> = (context: MutationCtx) => Promise<R> | R;
121
470
  type InlineActionFunction<R> = (context: ActionCtx) => Promise<R> | R;
122
471
  /**
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
- */
472
+ * A map from function path strings (e.g. `"messages:send"`) to their
473
+ * registered function objects. Used by the fake scheduler to resolve
474
+ * `ctx.scheduler.runAfter(delay, "messages:send", args)` → handler invocation.
475
+ *
476
+ * Only mutations and actions can be scheduled in production; queries passed
477
+ * here will be accepted but produce a console.warn at dispatch time.
478
+ *
479
+ * The value type uses `any` because `RegisteredFunction` is contravariant in its
480
+ * args type parameter — a `RegisteredMutation` with concrete args is not assignable
481
+ * to `RegisteredMutation` with `ArgsValidator` at the type level even though at
482
+ * runtime it is sound (the fake scheduler passes `Record<string, unknown>` to
483
+ * `handler` and ignores the return value).
484
+ */
136
485
  type FunctionRegistry = Record<string, RegisteredAction<any, any> | RegisteredMutation<any, any> | RegisteredQuery<any, any>>;
137
486
  /**
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
- */
487
+ * Options accepted by {@link lunoraTest}.
488
+ *
489
+ * All options are optional — `lunoraTest(schema)` preserves v1 behaviour with
490
+ * clearly-throwing stubs for unsupported surfaces.
491
+ */
143
492
  interface LunoraTestOptions {
144
493
  /**
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
- */
494
+ * Enforce the secure-by-default RLS guard on the writer registered
495
+ * procedures dispatch through (`query`/`mutation`/`action`, via
496
+ * `reference.handler`) — the same `enforceRls: true` production's generated
497
+ * `buildCtx` always passes. Under a `.rls("required")` schema, a procedure
498
+ * that touches a known, non-`.public()` table without `.use(rls(...))` in
499
+ * its chain rejects with `RlsRequiredError`, exactly as it would on first
500
+ * dispatch in production. Defaults to `true` so a green suite means the
501
+ * deploy is RLS-safe; the harness's other surfaces — `t.run` and any
502
+ * `@lunora/seed` helper built on it — stay on the trusted, UNGUARDED writer
503
+ * regardless of this flag (mirroring production's admin/migration system
504
+ * paths).
505
+ *
506
+ * Set to `false` to opt back into the pre-guard permissive behaviour (every
507
+ * `lunoraTest` release before this option existed): every procedure's
508
+ * `ctx.db` goes unguarded even under a `.rls("required")` schema. This
509
+ * forfeits the "a passing suite means the deploy is safe" guarantee — a
510
+ * procedure that forgot `.use(rls(...))` will pass in tests and throw
511
+ * `RlsRequiredError` on its first production request. No effect when the
512
+ * schema does not declare `.rls("required")` (the guard is a no-op there
513
+ * either way).
514
+ * @default true
515
+ * @example
516
+ * ```ts
517
+ * // Restores the old permissive behavior for a suite not yet migrated.
518
+ * const t = lunoraTest(schema, { enforceRls: false });
519
+ * ```
520
+ */
521
+ enforceRls?: boolean;
522
+ /**
523
+ * Injectable `ctx.env` for every context (query / mutation / action). When
524
+ * provided, handlers that read `ctx.env.SOME_KEY` (the validated `defineEnv`
525
+ * surface) see this object. Left unset it stays `undefined` — matching the
526
+ * optional `ctx.env?` field, so graceful `ctx.env?.KEY` access still yields
527
+ * `undefined` rather than throwing. Not a throwing stub for exactly that
528
+ * reason: `env` is designed to be legitimately absent.
529
+ * @example
530
+ * ```ts
531
+ * const t = lunoraTest(schema, { env: { STRIPE_KEY: "sk_test_…" } });
532
+ * ```
533
+ */
534
+ env?: Record<string, unknown>;
535
+ /**
536
+ * Injectable `fetch` implementation for action contexts. When provided,
537
+ * `ctx.fetch` in every `action` (and `withIdentity` views) resolves to this
538
+ * function rather than throwing the "not available in v1" stub.
539
+ *
540
+ * Pass `vi.fn()` or any `typeof globalThis.fetch` compatible implementation.
541
+ * @example
542
+ * ```ts
543
+ * const fakeFetch = vi.fn(async () => Response.json({ ok: true }));
544
+ * const t = lunoraTest(schema, { fetch: fakeFetch });
545
+ * ```
546
+ */
156
547
  fetch?: typeof globalThis.fetch;
157
548
  /**
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
- */
549
+ * Function registry for the fake in-memory scheduler. Maps a
550
+ * `functionPath` string (the value passed as the second argument to
551
+ * `ctx.scheduler.runAfter` / `ctx.scheduler.runAt`) to the corresponding
552
+ * registered function object.
553
+ *
554
+ * Only required if your handlers schedule work. Scheduled jobs for paths
555
+ * NOT listed here produce a `console.warn` at dispatch time (matching prod
556
+ * behaviour for unknown paths).
557
+ * @example
558
+ * ```ts
559
+ * const t = lunoraTest(schema, {
560
+ * functions: { "messages:send": sendMutation },
561
+ * });
562
+ * ```
563
+ */
173
564
  functions?: FunctionRegistry;
174
565
  /**
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
- */
566
+ * The app's `lunora/notify.ts` default export. Enables `ctx.notify` and its
567
+ * `ctx.push` alias on every context, built by `@lunora/notify`'s own
568
+ * `createNotify` — so register / list / unregister / broadcast behave as in
569
+ * production, and channels are wired exactly when production wires them
570
+ * (each factory is called with `options.env`; a push target for a transport
571
+ * that resolved to nothing fails). Differences: deliveries are recorded and
572
+ * report accepted instead of reaching a service (no send-time DNS re-check,
573
+ * retry or circuit breaker), and subscriptions live in an in-memory store —
574
+ * the definition's `store` is never called. Assert on deliveries with
575
+ * `harness.notify.sent()`. Left unset, touching `ctx.notify` / `ctx.push` —
576
+ * or calling `harness.notify.sent()` — throws, naming this option.
577
+ * @example
578
+ * ```ts
579
+ * import notify from "../lunora/notify";
580
+ *
581
+ * const t = lunoraTest(schema, { notify });
582
+ * await t.action(alertOwner, { orderId });
583
+ * expect(t.notify.sent("push")).toHaveLength(1);
584
+ * ```
585
+ */
586
+ notify?: NotifyDefinition;
587
+ /**
588
+ * Fixed value for `ctx.now` (epoch ms) in every context. Production captures
589
+ * `Date.now()` once per execution; in tests a fixed `now` makes time-dependent
590
+ * handlers deterministic. Defaults to the wall clock at harness creation.
591
+ * @example
592
+ * ```ts
593
+ * const t = lunoraTest(schema, { now: 1_700_000_000_000 });
594
+ * ```
595
+ */
183
596
  now?: number;
597
+ /**
598
+ * The export names of the queues in `lunora/queues.ts` (the keys of
599
+ * `ctx.queues`). Each gets a recording producer on mutation and action
600
+ * contexts, behind `@lunora/queue`'s own validation (batch cap, delay
601
+ * ceiling), so a send the platform would refuse rejects here too. A name not
602
+ * listed rejects on use, as an undeclared queue does in production. Assert
603
+ * on sends with `harness.queues.sent(name)`. Left unset, touching
604
+ * `ctx.queues` — or calling `harness.queues.sent()` — throws, naming this
605
+ * option.
606
+ * @example
607
+ * ```ts
608
+ * const t = lunoraTest(schema, { queues: ["jobs"] });
609
+ * await t.mutation(enqueueJob, { id: "a" });
610
+ * expect(t.queues.sent("jobs")).toEqual([{ body: { id: "a" }, queue: "jobs" }]);
611
+ * ```
612
+ */
613
+ queues?: ReadonlyArray<string>;
614
+ /**
615
+ * Fakes for `ctx.services` (the `lunora.config` `services` an app calls),
616
+ * keyed like `ctx.services`. Set on action contexts only — queries and
617
+ * mutations have no `ctx.services` at runtime either. A key with no fake
618
+ * throws on use, naming the option to add it to.
619
+ * @example
620
+ * ```ts
621
+ * const t = lunoraTest(schema, {
622
+ * services: { documentParser: { fetch: vi.fn(async () => Response.json({ text: "…" })) } },
623
+ * });
624
+ * ```
625
+ */
626
+ services?: Record<string, object>;
627
+ /**
628
+ * The export names of the topics in `lunora/queues.ts` (the keys of
629
+ * `ctx.topics`). Each gets a recording publisher on mutation and action
630
+ * contexts, built by `@lunora/queue`'s own `createTopicContext` — so the
631
+ * batch cap, delay ceiling and reserved-key check reject as in production,
632
+ * and a name not listed rejects on use. Bodies are recorded and size-checked
633
+ * as `queues` records them, once per message however many subscriptions the
634
+ * app declares; no subscription handler runs. Assert with
635
+ * `harness.topics.published(name)`. Left unset, touching `ctx.topics` — or
636
+ * calling `harness.topics.published()` — throws, naming this option.
637
+ * @example
638
+ * ```ts
639
+ * const t = lunoraTest(schema, { topics: ["signups"] });
640
+ * await t.mutation(signUp, { email: "a@example.test" });
641
+ * expect(t.topics.published("signups")).toHaveLength(1);
642
+ * ```
643
+ */
644
+ topics?: ReadonlyArray<string>;
184
645
  }
185
646
  /**
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
- */
647
+ * The in-memory test harness returned by {@link lunoraTest}. Mirrors the first
648
+ * five methods of Convex's `convexTest`: `query` / `mutation` / `action` / `run`
649
+ * / `withIdentity`. All five share one in-memory `node:sqlite` backend, so a
650
+ * write from one method is visible to a read from another (including across a
651
+ * `withIdentity` scope).
652
+ */
192
653
  interface TestHarness {
193
654
  /** Run a registered `action` (or an inline `async (context) => …`) against the harness. */
194
655
  action: {
@@ -202,74 +663,283 @@ interface TestHarness {
202
663
  <A extends ArgsValidator, R>(reference: RegisteredMutation<A, R>, args: InferArgs<A>): Promise<R>;
203
664
  <R>(inline: InlineMutationFunction<R>): Promise<R>;
204
665
  };
666
+ /**
667
+ * What handlers delivered through `ctx.notify` / `ctx.push` (enabled by
668
+ * `options.notify`): `sent(channel?)` in send order, `clear()` to reset.
669
+ * Both throw when the option was not passed. Shared with any `withIdentity` view.
670
+ */
671
+ notify: FakeNotifyControls;
205
672
  /** Run a registered `query` (or an inline `async (context) => …`) against the harness. */
206
673
  query: {
207
674
  <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>): Promise<R>;
208
675
  <R>(inline: InlineQueryFunction<R>): Promise<R>;
209
676
  };
210
- /** Direct db access at mutation-level (read + write), mirroring `convexTest`'s `run`. */
677
+ /**
678
+ * What handlers enqueued through `ctx.queues` (declared by `options.queues`):
679
+ * `sent(name?)` in send order, `clear()` to reset. Sends are not
680
+ * transactional in production, so one made by a mutation that then threw is
681
+ * recorded too. Both throw when the option was not passed. Shared with any
682
+ * `withIdentity` view.
683
+ */
684
+ queues: FakeQueueControls;
685
+ /**
686
+ * Direct db access at mutation-level (read + write), mirroring `convexTest`'s
687
+ * `run`. This is the harness's trusted escape hatch: `ctx.db` here is always
688
+ * the UNGUARDED writer, regardless of `options.enforceRls` or the schema's
689
+ * RLS mode — seeding/asserting against a protected table never trips the
690
+ * secure-by-default guard. A `ctx.runMutation`/`ctx.runQuery` call from
691
+ * inside the body still dispatches the target as a real registered
692
+ * procedure, so it is guarded exactly as `t.mutation`/`t.query` would guard
693
+ * it.
694
+ */
211
695
  run: <R>(function_: InlineMutationFunction<R>) => Promise<R>;
212
696
  /**
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
- */
697
+ * Controls for the fake in-memory scheduler. Always present; scheduler
698
+ * jobs only execute when you call `advance(ms)` or `runPending()`.
699
+ *
700
+ * - `list()` — snapshot of all pending jobs (enqueue order).
701
+ * - `advance(ms)` — tick the virtual clock forward by `ms` ms, executing every job
702
+ * whose `scheduledFor` is now at or below virtual now.
703
+ * - `runPending()` — execute all currently pending jobs regardless of their scheduled time.
704
+ *
705
+ * Scheduled jobs run through the same `runInternal` dispatch as
706
+ * `ctx.runMutation`, so they share the harness SQLite database.
707
+ */
224
708
  scheduler: FakeSchedulerControls;
225
709
  /**
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
- */
710
+ * Subscribe to a registered query (or inline query function) and receive
711
+ * an async iterable of snapshots. The first value is emitted immediately
712
+ * (the current query result). Subsequent values are emitted after each
713
+ * `mutation` / `run` call on this harness completes.
714
+ *
715
+ * Subscriptions are table-agnostic — any mutation triggers a re-evaluation.
716
+ * This matches the harness's single-writer model and keeps the implementation
717
+ * free of DO machinery.
718
+ * @example
719
+ * ```ts
720
+ * const sub = t.subscribe(list, {});
721
+ * const first = await sub.next(); // current result
722
+ * await t.mutation(send, { author: "ada", body: "hi" });
723
+ * const second = await sub.next(); // updated result
724
+ * await sub.return(); // unsubscribe
725
+ * ```
726
+ *
727
+ * The iterable is lazy — it never buffers more than one pending result.
728
+ * If you do not consume fast enough and multiple mutations fire, the next
729
+ * `next()` call will reflect the most-recent state (intermediate snapshots
730
+ * are coalesced).
731
+ */
248
732
  subscribe: {
249
733
  <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>): TestSubscription<R>;
250
734
  <R>(inline: InlineQueryFunction<R>): TestSubscription<R>;
251
735
  };
736
+ /**
737
+ * What handlers published through `ctx.topics` (declared by `options.topics`):
738
+ * `published(name?)` in publish order, `clear()` to reset. Like queue sends,
739
+ * publishes are not transactional. Both throw when the option was not passed.
740
+ * Shared with any `withIdentity` view.
741
+ */
742
+ topics: FakeTopicControls;
743
+ /**
744
+ * What handlers attached to `ctx.span` — the **wide event** — during this
745
+ * harness's runs, so a test can assert the instrumentation itself:
746
+ *
747
+ * ```ts
748
+ * await t.mutation(checkout, { items: 3 });
749
+ * expect(t.wideEvent().attributes["cart.items"]).toBe(3);
750
+ * ```
751
+ *
752
+ * Accumulates across calls on this view (it is not reset per run), mirroring
753
+ * the harness's single shared database. Shared with any `withIdentity` view.
754
+ */
755
+ wideEvent: () => RecordedWideEvent;
252
756
  /** Return a harness view that shares this harness's db but reports the given identity on `ctx.auth`. */
253
757
  withIdentity: (identity: TestIdentity) => TestHarness;
254
758
  }
255
759
  /**
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
- */
760
+ * Spin up an in-memory Lunora function harness for `schema`.
761
+ *
762
+ * `lunoraTest(schema)` runs the migrations against a fresh `node:sqlite`
763
+ * database, builds the same `ctx.db` writer the real Durable Object builds (via
764
+ * `@lunora/shard-engine`'s `createShardCtxDb`, with the same `enforceRls: true`
765
+ * production's generated `buildCtx` passes), and returns a harness whose
766
+ * `query` / `mutation` / `action` execute a registered function's `handler`
767
+ * directly — no Durable Object, no `wrangler`, no network. Under a
768
+ * `.rls("required")` schema a procedure missing `.use(rls(...))` therefore
769
+ * rejects here exactly as it would on its first production dispatch — see
770
+ * `LunoraTestOptions.enforceRls` to opt out, and `run`'s doc for the trusted
771
+ * escape hatch (always unguarded).
772
+ *
773
+ * **v1 surfaces now supported:**
774
+ *
775
+ * - `ctx.env` (all contexts): inject the validated env via `options.env`; unset it
776
+ * stays `undefined`, matching the optional `ctx.env?` field.
777
+ * - `ctx.fetch` (actions): inject a custom `fetch` via `options.fetch`.
778
+ * - `ctx.scheduler` (mutations + actions): fully functional fake with virtual clock;
779
+ * control via `harness.scheduler.advance(ms)` / `runPending()` / `list()`. As in
780
+ * production it is transactional inside a mutation: a job scheduled by a mutation
781
+ * that then throws never becomes pending.
782
+ * - `ctx.queues` (mutations + actions): recording producers for `options.queues`;
783
+ * inspect via `harness.queues.sent(name)`.
784
+ * - `ctx.topics` (mutations + actions): recording publishers for `options.topics`;
785
+ * inspect via `harness.topics.published(name)`.
786
+ * - `ctx.notify` / `ctx.push` (all contexts): built from `options.notify` with
787
+ * recorded deliveries and in-memory subscriptions; inspect via `harness.notify.sent()`.
788
+ * - `harness.subscribe(query, args)`: async iterable that re-emits after mutations.
789
+ *
790
+ * **v1 stubs (still throwing):** `ctx.storage`, `ctx.vectors`, `ctx.workflows`.
791
+ * These are clearly documented follow-ups.
792
+ */
274
793
  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 };
794
+ /** One sample handed to a {@link Scorer}. */
795
+ interface ScorerSample {
796
+ /** The reference/gold answer, when the scorer compares against one. */
797
+ expected?: string;
798
+ /** The prompt/input that produced the output (for LLM-judge context). */
799
+ input?: string;
800
+ /** Arbitrary per-sample metadata carried through. */
801
+ metadata?: Record<string, unknown>;
802
+ /** The model/agent output under test. */
803
+ output: string;
804
+ }
805
+ /** A scorer's verdict: a `[0, 1]` score and an optional human-readable reason. */
806
+ interface ScoreResult {
807
+ reason?: string;
808
+ score: number;
809
+ }
810
+ /** A named scorer — returns a `[0, 1]` score (or a {@link ScoreResult}) for a sample. */
811
+ interface Scorer {
812
+ name: string;
813
+ score: (sample: ScorerSample) => Promise<ScoreResult | number> | ScoreResult | number;
814
+ }
815
+ /**
816
+ * What a `produce` runner may return: the output text alone, or the text plus
817
+ * metadata describing what the run actually did.
818
+ *
819
+ * The metadata form exists for scorers that judge more than the final string —
820
+ * a retrieval scorer needs the ranked ids the run retrieved, which only the run
821
+ * can know. It is merged OVER the case's own metadata before scoring.
822
+ */
823
+ interface ProducedOutput {
824
+ /** Merged over the case's `metadata`, then handed to every scorer. */
825
+ metadata?: Record<string, unknown>;
826
+ /** The output text under test. */
827
+ output: string;
828
+ }
829
+ /** One dataset case: an input and its optional gold answer/metadata. */
830
+ interface EvalCase {
831
+ expected?: string;
832
+ input: string;
833
+ metadata?: Record<string, unknown>;
834
+ }
835
+ /** The per-case eval result: the produced output plus each scorer's verdict and the mean. */
836
+ interface EvalItemResult {
837
+ average: number;
838
+ input: string;
839
+ output: string;
840
+ scores: Record<string, ScoreResult>;
841
+ }
842
+ /** The whole eval run: per-case results plus the mean of their averages. */
843
+ interface EvalResult {
844
+ average: number;
845
+ items: EvalItemResult[];
846
+ }
847
+ /** Score 1 if the output contains `needle` (case-insensitive unless `caseSensitive`). */
848
+ declare const containsScorer: (needle: string, options?: {
849
+ caseSensitive?: boolean;
850
+ }) => Scorer;
851
+ /** Score 1 when `pattern` matches the output. */
852
+ declare const regexScorer: (pattern: RegExp, name?: string) => Scorer;
853
+ /** Score 1 when the trimmed output exactly equals the trimmed `expected`. */
854
+ declare const exactMatchScorer: () => Scorer;
855
+ /** Score the fraction of `keywords` (case-insensitive) present in the output. */
856
+ declare const keywordScorer: (keywords: ReadonlyArray<string>) => Scorer;
857
+ /**
858
+ * An LLM-as-judge scorer. `judge` is INJECTED — a `(prompt) => Promise<string>`
859
+ * you wire to your model (e.g. via `ctx.ai` / `generateText`), so this module
860
+ * stays model-agnostic and the judge is mockable in tests. It returns the model's
861
+ * numeric verdict (`[0, 1]`) plus its reason.
862
+ */
863
+ declare const llmScorer: (options: {
864
+ criteria: string;
865
+ judge: (prompt: string) => Promise<string>;
866
+ name?: string;
867
+ }) => Scorer;
868
+ /** Run every scorer over one sample (concurrently) → each verdict keyed by name + their mean. */
869
+ declare const scoreSample: (sample: ScorerSample, scorers: ReadonlyArray<Scorer>) => Promise<{
870
+ average: number;
871
+ scores: Record<string, ScoreResult>;
872
+ }>;
873
+ /**
874
+ * Run a dataset through `produce` (e.g. an `agentHarness.run` wrapper returning
875
+ * the output text) and score each output with `scorers`. Cases run concurrently;
876
+ * give each its own thread/key inside `produce` if the producer is stateful.
877
+ * Returns per-case results plus the mean of their averages.
878
+ */
879
+ declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<ProducedOutput | string> | ProducedOutput | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
880
+ /** Where a retrieval scorer reads its two id lists from. */
881
+ interface RetrievalScorerOptions {
882
+ /** Metadata key holding the gold relevant ids. Default `"relevant"`. */
883
+ relevantKey?: string;
884
+ /** Metadata key holding the run's ranked retrieved ids. Default `"retrieved"`. */
885
+ retrievedKey?: string;
886
+ }
887
+ /**
888
+ * Recall@k scorer — what fraction of the gold passages made it into the top
889
+ * `k`.
890
+ *
891
+ * This is the ceiling on everything downstream: a passage retrieval never
892
+ * returned is one no reranker can promote and no prompt can cite. Omit `k` to
893
+ * score the whole retrieved list.
894
+ */
895
+ declare const recallAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
896
+ /**
897
+ * Precision@k scorer — what fraction of the top `k` retrieved passages are
898
+ * gold.
899
+ *
900
+ * This is the counterweight to recall: padding `topK` raises recall for free
901
+ * while burying the answer in noise the model has to read past, and pay for.
902
+ * Omit `k` to score the whole retrieved list.
903
+ */
904
+ declare const precisionAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
905
+ /**
906
+ * Mean Reciprocal Rank scorer — `1 / rank` of the first gold passage: 1 if it
907
+ * is first, 0.5 if second, 0 if absent.
908
+ *
909
+ * This is the metric that notices ordering, which recall cannot. A gold passage
910
+ * at rank 20 counts the same as rank 1 for recall@20, but only one of those
911
+ * survives a context-window trim or a model that skims the top of its prompt.
912
+ */
913
+ declare const mrrScorer: (options?: RetrievalScorerOptions) => Scorer;
914
+ /**
915
+ * Normalized Discounted Cumulative Gain scorer — relevance discounted
916
+ * logarithmically by rank, divided by the best achievable arrangement.
917
+ *
918
+ * This is the one to gate on when comparing retrieval strategies. Unlike recall
919
+ * it is sensitive to order, and unlike MRR it credits every gold passage rather
920
+ * than only the first — so it is the metric that can actually say whether a
921
+ * reranker or a hybrid leg helped.
922
+ *
923
+ * Relevance is binary: an id is gold or it is not, which is what a gold-id set
924
+ * expresses. Graded relevance would need per-id weights.
925
+ */
926
+ declare const ndcgAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
927
+ /**
928
+ * Groundedness scorer — does the answer assert only what the retrieved context
929
+ * supports?
930
+ *
931
+ * This is the generation-side counterpart to the metrics above: perfect
932
+ * retrieval still fails if the model answers from its own weights. It scores
933
+ * the output against the context via an injected `judge`, the same shape
934
+ * `llmScorer` takes, so this stays model-agnostic and mockable.
935
+ *
936
+ * Reads the context from `metadata.context` by default — the `context` string
937
+ * `retrieve()` already returns. Fails closed: no context means nothing could
938
+ * have grounded the answer.
939
+ */
940
+ declare const groundednessScorer: (options: {
941
+ contextKey?: string;
942
+ judge: (prompt: string) => Promise<string>;
943
+ name?: string;
944
+ }) => Scorer;
945
+ export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type ArtifactsFake, type EvalCase, type EvalItemResult, type EvalResult, type EvaluationAttributeValue, type EvaluationMetrics, type EvaluationSpanHandle, type FakeNotifyControls, type FakeQueueControls, type FakeScheduledJob, type FakeSchedulerControls, type FakeTopicControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type NotifyChannel, type ProducedOutput, type PublishedTopicMessage, type RecordEvaluationInput, type RetrievalScorerOptions, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SentNotification, type SentQueueMessage, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, createArtifactsFake, evaluate, evaluationAttributes, exactMatchScorer, finalTurn, groundednessScorer, keywordScorer, llmScorer, lunoraTest, mrrScorer, ndcgAtK, precisionAtK, recallAtK, recordEvaluation, regexScorer, scoreSample, toolCallTurn };