@lunora/testing 1.0.0-alpha.5 → 1.0.0-alpha.51

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.
@@ -0,0 +1,144 @@
1
+ import { runAgentLoop, DEFAULT_AGENT_FUNCTION_PATHS } from '@lunora/agent';
2
+
3
+ const scriptedGenerate = (script) => {
4
+ const remaining = [...script];
5
+ return () => {
6
+ const next = remaining.shift();
7
+ if (!next) {
8
+ throw new Error("agentHarness: scripted generate exhausted — the run took more turns than the script provided.");
9
+ }
10
+ return Promise.resolve(next);
11
+ };
12
+ };
13
+ class DurableStepJournal {
14
+ invoked = [];
15
+ entries = /* @__PURE__ */ new Map();
16
+ async do(name, callback) {
17
+ const existing = this.entries.get(name);
18
+ if (existing) {
19
+ return existing.output;
20
+ }
21
+ this.invoked.push(name);
22
+ const output = await callback();
23
+ this.entries.set(name, { output });
24
+ return output;
25
+ }
26
+ // eslint-disable-next-line class-methods-use-this -- mirrors AgentStepLike.waitForEvent's host signature so the mock stays assignable
27
+ async waitForEvent(_name, _options) {
28
+ return new Promise(() => {
29
+ });
30
+ }
31
+ }
32
+ const createRuntime = (extra) => {
33
+ const threads = /* @__PURE__ */ new Map();
34
+ const messages = /* @__PURE__ */ new Map();
35
+ const dispatches = [];
36
+ const ensureThread = (args) => {
37
+ const key = args?.["key"];
38
+ const instanceId = args?.["instanceId"];
39
+ const existing = threads.get(key);
40
+ if (existing) {
41
+ existing.status = "running";
42
+ delete existing.error;
43
+ if (instanceId !== void 0) {
44
+ existing.instanceId = instanceId;
45
+ }
46
+ return { created: false };
47
+ }
48
+ threads.set(key, {
49
+ agent: args?.["agent"],
50
+ instanceId,
51
+ key,
52
+ messageCount: 0,
53
+ owner: args?.["owner"],
54
+ status: "running",
55
+ title: args?.["title"]
56
+ });
57
+ return { created: true };
58
+ };
59
+ const appendMessage = (args) => {
60
+ const threadKey = args?.["threadKey"];
61
+ const messageKey = args?.["messageKey"];
62
+ const id = `${threadKey}:${messageKey}`;
63
+ const existing = messages.get(id);
64
+ if (existing) {
65
+ return { seq: existing.seq };
66
+ }
67
+ const thread = threads.get(threadKey);
68
+ if (!thread) {
69
+ throw new Error(`agentHarness: append to unknown thread "${threadKey}".`);
70
+ }
71
+ const seq = thread.messageCount;
72
+ thread.messageCount += 1;
73
+ messages.set(id, { ...args, seq });
74
+ return { seq };
75
+ };
76
+ const listMessages = (args) => {
77
+ const key = args?.["key"];
78
+ return [...messages.values()].filter((message) => message.threadKey === key).toSorted((a, b) => a.seq - b.seq);
79
+ };
80
+ const patchThread = (args) => {
81
+ const thread = threads.get(args?.["key"]);
82
+ if (thread) {
83
+ for (const [field, value] of Object.entries(args ?? {})) {
84
+ if (field !== "key" && value !== void 0) {
85
+ thread[field] = value;
86
+ }
87
+ }
88
+ }
89
+ return void 0;
90
+ };
91
+ const handlers = new Map([
92
+ [DEFAULT_AGENT_FUNCTION_PATHS.appendMessage, appendMessage],
93
+ [DEFAULT_AGENT_FUNCTION_PATHS.ensureThread, ensureThread],
94
+ [DEFAULT_AGENT_FUNCTION_PATHS.listMessages, listMessages],
95
+ [DEFAULT_AGENT_FUNCTION_PATHS.patchThread, patchThread],
96
+ ...Object.entries(extra)
97
+ ]);
98
+ const run = (reference, args) => {
99
+ const path = reference["__lunoraRef"];
100
+ dispatches.push({ args, path });
101
+ const handler = handlers.get(path);
102
+ if (!handler) {
103
+ throw new Error(`agentHarness: unstubbed dispatch "${path}" — pass a \`functions\` handler for it.`);
104
+ }
105
+ return Promise.resolve(handler(args));
106
+ };
107
+ return { dispatches, messages, run, threads };
108
+ };
109
+ const agentHarness = (agent, options) => {
110
+ const exportName = options.exportName ?? "agent";
111
+ const env = options.env ?? { LUNORA_TEST: true };
112
+ const runtime = createRuntime(options.functions ?? {});
113
+ let runCount = 0;
114
+ const run = (params, overrides) => {
115
+ runCount += 1;
116
+ const instanceId = overrides?.instanceId ?? `wf-${String(runCount)}`;
117
+ const script = overrides?.script ?? options.script;
118
+ return runAgentLoop({
119
+ agent,
120
+ env,
121
+ exportName,
122
+ generate: scriptedGenerate(script),
123
+ instanceId,
124
+ params,
125
+ paths: DEFAULT_AGENT_FUNCTION_PATHS,
126
+ run: runtime.run,
127
+ step: new DurableStepJournal()
128
+ });
129
+ };
130
+ return {
131
+ dispatches: runtime.dispatches,
132
+ messages: (threadKey) => [...runtime.messages.values()].filter((message) => message.threadKey === threadKey).toSorted((a, b) => a.seq - b.seq).map(({ messageKey: _messageKey, threadKey: _threadKey, ...row }) => row),
133
+ run,
134
+ thread: (threadKey) => runtime.threads.get(threadKey)
135
+ };
136
+ };
137
+ const finalTurn = (text, extra) => {
138
+ return { text, toolCalls: [], ...extra };
139
+ };
140
+ const toolCallTurn = (id, name, input, text = "") => {
141
+ return { text, toolCalls: [{ id, input, name }] };
142
+ };
143
+
144
+ export { agentHarness, finalTurn, toolCallTurn };
@@ -0,0 +1,94 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
3
+ const LEADING_SCORE = /^\s*(-?\d+(?:\.\d+)?)/u;
4
+ const clamp01 = (value) => Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
5
+ const normalizeScore = (result) => {
6
+ if (typeof result === "number") {
7
+ return { score: clamp01(result) };
8
+ }
9
+ return { score: clamp01(result.score), ...result.reason === void 0 ? {} : { reason: result.reason } };
10
+ };
11
+ const mean = (values) => values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
12
+ const containsScorer = (needle, options = {}) => {
13
+ return {
14
+ name: `contains:${needle}`,
15
+ score: ({ output }) => {
16
+ const haystack = options.caseSensitive ? output : output.toLowerCase();
17
+ const target = options.caseSensitive ? needle : needle.toLowerCase();
18
+ return haystack.includes(target) ? 1 : 0;
19
+ }
20
+ };
21
+ };
22
+ const regexScorer = (pattern, name = "regex") => {
23
+ return { name, score: ({ output }) => pattern.test(output) ? 1 : 0 };
24
+ };
25
+ const exactMatchScorer = () => {
26
+ return {
27
+ name: "exact-match",
28
+ score: ({ expected, output }) => output.trim() === expected?.trim() ? 1 : 0
29
+ };
30
+ };
31
+ const keywordScorer = (keywords) => {
32
+ if (keywords.length === 0) {
33
+ throw new LunoraError("BAD_REQUEST", "@lunora/testing: keywordScorer requires at least one keyword");
34
+ }
35
+ return {
36
+ name: "keyword-coverage",
37
+ score: ({ output }) => {
38
+ const lower = output.toLowerCase();
39
+ const hits = keywords.filter((keyword) => lower.includes(keyword.toLowerCase())).length;
40
+ return { reason: `${String(hits)}/${String(keywords.length)} keywords present`, score: hits / keywords.length };
41
+ }
42
+ };
43
+ };
44
+ const buildJudgePrompt = (criteria, sample) => [
45
+ `Rate the ASSISTANT OUTPUT against this criterion: ${criteria}`,
46
+ "Respond with a single number from 0 (fails) to 1 (fully meets), then a dash and a one-line reason.",
47
+ ...sample.input === void 0 ? [] : ["", `Input: ${sample.input}`],
48
+ ...sample.expected === void 0 ? [] : ["", `Reference answer: ${sample.expected}`],
49
+ "",
50
+ `Assistant output: ${sample.output}`
51
+ ].join("\n");
52
+ const parseJudgeScore = (raw) => {
53
+ const match = LEADING_SCORE.exec(raw);
54
+ return { reason: raw.trim(), score: match ? clamp01(Number(match[1])) : 0 };
55
+ };
56
+ const llmScorer = (options) => {
57
+ return {
58
+ name: options.name ?? "llm-judge",
59
+ score: async (sample) => parseJudgeScore(await options.judge(buildJudgePrompt(options.criteria, sample)))
60
+ };
61
+ };
62
+ const scoreSample = async (sample, scorers) => {
63
+ const results = await Promise.all(
64
+ scorers.map(async (scorer) => {
65
+ return { name: scorer.name, result: normalizeScore(await scorer.score(sample)) };
66
+ })
67
+ );
68
+ const scores = {};
69
+ const seen = /* @__PURE__ */ new Map();
70
+ for (const { name, result } of results) {
71
+ const priorCount = seen.get(name) ?? 0;
72
+ seen.set(name, priorCount + 1);
73
+ scores[priorCount === 0 ? name : `${name}#${String(priorCount + 1)}`] = result;
74
+ }
75
+ return { average: mean(results.map(({ result }) => result.score)), scores };
76
+ };
77
+ const evaluate = async (cases, produce, scorers) => {
78
+ const items = await Promise.all(
79
+ cases.map(async (testCase) => {
80
+ const output = await produce(testCase.input);
81
+ const sample = {
82
+ input: testCase.input,
83
+ output,
84
+ ...testCase.expected === void 0 ? {} : { expected: testCase.expected },
85
+ ...testCase.metadata === void 0 ? {} : { metadata: testCase.metadata }
86
+ };
87
+ const { average, scores } = await scoreSample(sample, scorers);
88
+ return { average, input: testCase.input, output, scores };
89
+ })
90
+ );
91
+ return { average: mean(items.map((item) => item.average)), items };
92
+ };
93
+
94
+ export { containsScorer, evaluate, exactMatchScorer, keywordScorer, llmScorer, regexScorer, scoreSample };
@@ -1,8 +1,9 @@
1
1
  import { runShardMigrations, createShardCtxDb } from '@lunora/do';
2
+ import { LunoraError } from '@lunora/errors';
2
3
  import { DatabaseSync } from 'node:sqlite';
3
4
 
4
- const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistry) => {
5
- let nowMs = Date.now();
5
+ const createFakeScheduler = (getDispatch, getMutationContext, getActionContext, getFunctionRegistry, now) => {
6
+ let nowMs = now;
6
7
  let nextId = 1;
7
8
  const pending = /* @__PURE__ */ new Map();
8
9
  const recordedFailures = [];
@@ -18,6 +19,7 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
18
19
  });
19
20
  return id;
20
21
  };
22
+ const targetPath = (target) => typeof target === "string" ? target : target.name ?? target.binding ?? "";
21
23
  const scheduler = {
22
24
  cancel: (id) => {
23
25
  const existed = pending.has(id);
@@ -27,12 +29,12 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
27
29
  // eslint-disable-next-line unicorn/no-null -- Scheduler.get returns null when absent (public API contract)
28
30
  get: (id) => Promise.resolve(pending.get(id) ?? null),
29
31
  list: () => Promise.resolve([...pending.values()]),
30
- runAfter: (delayMs, functionPath, args) => {
31
- const id = enqueue(nowMs + delayMs, functionPath, args);
32
+ runAfter: (delayMs, target, args) => {
33
+ const id = enqueue(nowMs + delayMs, targetPath(target), args);
32
34
  return Promise.resolve(id);
33
35
  },
34
- runAt: (timestampMs, functionPath, args) => {
35
- const id = enqueue(timestampMs, functionPath, args);
36
+ runAt: (timestampMs, target, args) => {
37
+ const id = enqueue(timestampMs, targetPath(target), args);
36
38
  return Promise.resolve(id);
37
39
  }
38
40
  };
@@ -46,8 +48,8 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
46
48
  }
47
49
  if (entry.kind === "mutation" || entry.kind === "action") {
48
50
  const dispatch = getDispatch();
49
- const mutationContext = getMutationContext();
50
- await dispatch(entry.kind, entry, mutationContext, job.args);
51
+ const context = entry.kind === "action" ? getActionContext() : getMutationContext();
52
+ await dispatch(entry.kind, entry, context, job.args);
51
53
  } else {
52
54
  console.warn(
53
55
  `[fake-scheduler] functionPath "${job.functionPath}" is a ${entry.kind} — only mutations and actions can be scheduled; job ${job.id} dropped`
@@ -57,7 +59,12 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
57
59
  const executeDue = async (cutoff) => {
58
60
  const due = [...pending.values()].filter((j) => j.scheduledFor <= cutoff).toSorted((a, b) => a.scheduledFor - b.scheduledFor);
59
61
  const failed = [];
62
+ let executed = 0;
60
63
  for (const job of due) {
64
+ if (!pending.has(job.id)) {
65
+ continue;
66
+ }
67
+ executed += 1;
61
68
  try {
62
69
  await dispatchJob(job);
63
70
  } catch (error) {
@@ -66,7 +73,7 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
66
73
  recordedFailures.push(failure);
67
74
  }
68
75
  }
69
- return { executed: due.length, failed };
76
+ return { executed, failed };
70
77
  };
71
78
  const runSweep = async (cutoff, options) => {
72
79
  const { executed, failed } = await executeDue(cutoff);
@@ -100,7 +107,7 @@ const createSqlExec = () => {
100
107
  return {
101
108
  one() {
102
109
  if (rows.length !== 1) {
103
- throw new Error(`expected exactly one row, received ${String(rows.length)}`);
110
+ throw new LunoraError("INTERNAL", `expected exactly one row, received ${String(rows.length)}`);
104
111
  }
105
112
  const [only] = rows;
106
113
  return only;
@@ -138,24 +145,34 @@ const registeredFunctionKind = (value) => {
138
145
  };
139
146
  const registeredFunctionVisibility = (value) => typeof value === "object" && value !== null && value.visibility === "internal" ? "internal" : "public";
140
147
  const unavailable = (surface) => {
141
- throw new Error(`ctx.${surface} is not available in the in-memory @lunora/testing harness (v1)`);
148
+ throw new LunoraError("INTERNAL", `ctx.${surface} is not available in the in-memory @lunora/testing harness (v1)`);
142
149
  };
143
150
  const stubProxy = (surface) => /* @__PURE__ */ new Proxy((..._args) => unavailable(surface), {
144
151
  apply: () => unavailable(surface),
145
152
  get: () => unavailable(surface)
146
153
  });
154
+ const passthroughTrace = async (_name, function_) => await function_(passthroughTrace);
155
+ const noopMetrics = {
156
+ count: () => void 0,
157
+ gauge: () => void 0,
158
+ record: () => void 0
159
+ };
147
160
  const noopLog = {
148
161
  debug: () => void 0,
149
162
  error: () => void 0,
163
+ fatal: () => void 0,
150
164
  info: () => void 0,
151
165
  log: () => void 0,
152
- warn: () => void 0
166
+ trace: () => void 0,
167
+ warn: () => void 0,
168
+ with: () => noopLog
153
169
  };
154
170
  const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
155
171
  const factory = (referenceOrInline, args) => {
156
172
  let done = false;
157
- let pendingResolve;
173
+ const pendingWaiters = [];
158
174
  let pendingResult;
175
+ let pendingError;
159
176
  let latestSeq = 0;
160
177
  let appliedSeq = 0;
161
178
  const runQuery = () => {
@@ -170,25 +187,46 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
170
187
  }
171
188
  appliedSeq = seq;
172
189
  const iterResult = { done: false, value };
173
- if (pendingResolve === void 0) {
190
+ if (pendingWaiters.length === 0) {
174
191
  pendingResult = iterResult;
192
+ pendingError = void 0;
175
193
  } else {
176
- const resolve = pendingResolve;
177
- pendingResolve = void 0;
178
194
  pendingResult = void 0;
179
- resolve(iterResult);
195
+ pendingError = void 0;
196
+ for (const waiter of pendingWaiters.splice(0)) {
197
+ waiter.resolve(iterResult);
198
+ }
199
+ }
200
+ };
201
+ const emitError = (seq, error) => {
202
+ if (seq < appliedSeq) {
203
+ return;
204
+ }
205
+ appliedSeq = seq;
206
+ if (pendingWaiters.length === 0) {
207
+ pendingError = { error };
208
+ pendingResult = void 0;
209
+ } else {
210
+ pendingResult = void 0;
211
+ pendingError = void 0;
212
+ for (const waiter of pendingWaiters.splice(0)) {
213
+ waiter.reject(error);
214
+ }
180
215
  }
181
216
  };
182
217
  const emitAt = (seq) => (value) => {
183
218
  emit(seq, value);
184
219
  };
220
+ const emitErrorAt = (seq) => (error) => {
221
+ emitError(seq, error);
222
+ };
185
223
  const listener = () => {
186
224
  if (done) {
187
225
  return;
188
226
  }
189
227
  latestSeq += 1;
190
228
  const seq = latestSeq;
191
- runQuery().then(emitAt(seq)).catch(() => void 0);
229
+ runQuery().then(emitAt(seq)).catch(emitErrorAt(seq));
192
230
  };
193
231
  mutationListeners.add(listener);
194
232
  const iterator = {
@@ -199,17 +237,29 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
199
237
  if (done) {
200
238
  return Promise.resolve({ done: true, value: void 0 });
201
239
  }
202
- if (pendingResult !== void 0 && appliedSeq === latestSeq) {
203
- const result = pendingResult;
204
- pendingResult = void 0;
205
- return Promise.resolve(result);
240
+ if (appliedSeq === latestSeq) {
241
+ if (pendingError !== void 0) {
242
+ const { error } = pendingError;
243
+ pendingError = void 0;
244
+ return Promise.reject(error);
245
+ }
246
+ if (pendingResult !== void 0) {
247
+ const result = pendingResult;
248
+ pendingResult = void 0;
249
+ return Promise.resolve(result);
250
+ }
206
251
  }
207
252
  if (appliedSeq < latestSeq) {
208
- return new Promise((resolve) => {
209
- pendingResolve = resolve;
253
+ return new Promise((resolve, reject) => {
254
+ pendingWaiters.push({ reject, resolve });
210
255
  });
211
256
  }
212
257
  return runQuery().then((value) => {
258
+ if (pendingError !== void 0) {
259
+ const { error } = pendingError;
260
+ pendingError = void 0;
261
+ throw error;
262
+ }
213
263
  if (pendingResult !== void 0) {
214
264
  const result = pendingResult;
215
265
  pendingResult = void 0;
@@ -221,15 +271,13 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
221
271
  return: () => {
222
272
  done = true;
223
273
  mutationListeners.delete(listener);
224
- if (pendingResolve !== void 0) {
225
- const resolve = pendingResolve;
226
- pendingResolve = void 0;
227
- resolve({ done: true, value: void 0 });
274
+ for (const waiter of pendingWaiters.splice(0)) {
275
+ waiter.resolve({ done: true, value: void 0 });
228
276
  }
229
277
  return Promise.resolve({ done: true, value: void 0 });
230
278
  }
231
279
  };
232
- runQuery().then(emitAt(0)).catch(() => void 0);
280
+ runQuery().then(emitAt(0)).catch(emitErrorAt(0));
233
281
  return iterator;
234
282
  };
235
283
  return factory;
@@ -243,26 +291,28 @@ const lunoraTest = (schema, options) => {
243
291
  const runner = sql.exec;
244
292
  runner.call(sql, statement);
245
293
  };
246
- let transactionDepth = 0;
247
- const runInMutationTransaction = async (function_) => {
248
- if (transactionDepth > 0) {
249
- return function_();
250
- }
251
- transactionDepth = 1;
252
- execStatement("BEGIN");
253
- try {
254
- const result = await function_();
255
- execStatement("COMMIT");
256
- return result;
257
- } catch (error) {
294
+ let mutationQueue = Promise.resolve();
295
+ const runInMutationTransaction = (function_) => {
296
+ const runTransaction = async () => {
297
+ execStatement("BEGIN");
258
298
  try {
259
- execStatement("ROLLBACK");
260
- } catch {
299
+ const result2 = await function_();
300
+ execStatement("COMMIT");
301
+ return result2;
302
+ } catch (error) {
303
+ try {
304
+ execStatement("ROLLBACK");
305
+ } catch {
306
+ }
307
+ throw error;
261
308
  }
262
- throw error;
263
- } finally {
264
- transactionDepth = 0;
265
- }
309
+ };
310
+ const result = mutationQueue.then(runTransaction);
311
+ mutationQueue = result.then(
312
+ () => void 0,
313
+ () => void 0
314
+ );
315
+ return result;
266
316
  };
267
317
  let closed = false;
268
318
  const closeDatabase = () => {
@@ -283,22 +333,39 @@ const lunoraTest = (schema, options) => {
283
333
  };
284
334
  let scheduledDispatchRef;
285
335
  let mutationContextRef;
336
+ let actionContextRef;
337
+ const harnessNow = options?.now ?? Date.now();
286
338
  const { controls: schedulerControls, scheduler: fakeScheduler } = createFakeScheduler(
287
339
  () => {
288
340
  if (scheduledDispatchRef === void 0) {
289
- throw new Error("[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed");
341
+ throw new LunoraError(
342
+ "INTERNAL",
343
+ "[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed"
344
+ );
290
345
  }
291
346
  return scheduledDispatchRef;
292
347
  },
293
348
  () => {
294
349
  if (mutationContextRef === void 0) {
295
- throw new Error("[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed");
350
+ throw new LunoraError(
351
+ "INTERNAL",
352
+ "[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed"
353
+ );
296
354
  }
297
355
  return mutationContextRef;
298
356
  },
299
- () => functionRegistryMap
357
+ () => {
358
+ if (actionContextRef === void 0) {
359
+ throw new LunoraError(
360
+ "INTERNAL",
361
+ "[fake-scheduler] actionContext not yet available — scheduler.advance called before harness construction completed"
362
+ );
363
+ }
364
+ return actionContextRef;
365
+ },
366
+ () => functionRegistryMap,
367
+ harnessNow
300
368
  );
301
- const harnessNow = options?.now ?? Date.now();
302
369
  const makeHarness = (identity) => {
303
370
  const auth = {
304
371
  // eslint-disable-next-line unicorn/no-null -- AuthState.getIdentity's anonymous sentinel is `null` (mirrors a decoded JWT being absent)
@@ -309,23 +376,31 @@ const lunoraTest = (schema, options) => {
309
376
  const queryContext = {
310
377
  auth,
311
378
  db: database,
379
+ env: options?.env,
312
380
  log: noopLog,
381
+ metrics: noopMetrics,
313
382
  now: harnessNow,
383
+ trace: passthroughTrace,
314
384
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runQuery, after construction completes
315
385
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
386
+ secrets: stubProxy("secrets"),
316
387
  storage: stubProxy("storage"),
317
388
  vectors: stubProxy("vectors")
318
389
  };
319
390
  const mutationContext = {
320
391
  auth,
321
392
  db: database,
393
+ env: options?.env,
322
394
  log: noopLog,
395
+ metrics: noopMetrics,
323
396
  now: harnessNow,
397
+ trace: passthroughTrace,
324
398
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runMutation, after construction completes
325
399
  runMutation: (reference, args) => runInternal("mutation", reference, mutationContext, args),
326
400
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runQuery, after construction completes
327
401
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
328
402
  scheduler: fakeScheduler,
403
+ secrets: stubProxy("secrets"),
329
404
  storage: stubProxy("storage"),
330
405
  vectors: stubProxy("vectors"),
331
406
  workflows: stubProxy("workflows")
@@ -334,10 +409,13 @@ const lunoraTest = (schema, options) => {
334
409
  const actionContext = {
335
410
  auth,
336
411
  db: database,
412
+ env: options?.env,
337
413
  // Use the injected fetch when provided; fall back to the v1 stub otherwise.
338
414
  fetch: options?.fetch ?? stubProxy("fetch"),
339
415
  log: noopLog,
416
+ metrics: noopMetrics,
340
417
  now: harnessNow,
418
+ trace: passthroughTrace,
341
419
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runAction, after construction completes
342
420
  runAction: (reference, args) => runInternal("action", reference, actionContext, args),
343
421
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runMutation, after construction completes
@@ -345,18 +423,21 @@ const lunoraTest = (schema, options) => {
345
423
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runQuery, after construction completes
346
424
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
347
425
  scheduler: fakeScheduler,
426
+ secrets: stubProxy("secrets"),
348
427
  storage: stubProxy("storage"),
349
428
  vectors: stubProxy("vectors"),
350
429
  workflows: stubProxy("workflows")
351
430
  };
431
+ actionContextRef ??= actionContext;
352
432
  const runRegistered = (expected, reference, context, args, allowInternal) => {
353
433
  const kind = registeredFunctionKind(reference);
354
434
  if (kind !== expected) {
355
- throw new Error(`expected a registered ${expected}, received a ${kind ?? "non-function"} reference`);
435
+ throw new LunoraError("INTERNAL", `expected a registered ${expected}, received a ${kind ?? "non-function"} reference`);
356
436
  }
357
437
  if (!allowInternal && registeredFunctionVisibility(reference) === "internal") {
358
- throw new Error(
359
- `"${expected}" is an internal function — it is unreachable from the external RPC boundary in production. Call it through ctx.run${expected.charAt(0).toUpperCase()}${expected.slice(1)} from another function instead.`
438
+ throw new LunoraError(
439
+ "INTERNAL",
440
+ `This ${expected} is an internal function — it is unreachable from the external RPC boundary in production. Call it through ctx.run${expected.charAt(0).toUpperCase()}${expected.slice(1)} from another function instead.`
360
441
  );
361
442
  }
362
443
  return Promise.resolve(reference.handler(context, args ?? {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/testing",
3
- "version": "1.0.0-alpha.5",
3
+ "version": "1.0.0-alpha.51",
4
4
  "description": "Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/testing"
26
26
  },
27
27
  "files": [
28
- "dist",
28
+ "./dist",
29
29
  "README.md",
30
30
  "LICENSE.md",
31
31
  "__assets__"
@@ -46,9 +46,11 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/do": "1.0.0-alpha.4",
50
- "@lunora/mail": "1.0.0-alpha.1",
51
- "@lunora/server": "1.0.0-alpha.2"
49
+ "@lunora/agent": "1.0.0-alpha.8",
50
+ "@lunora/do": "1.0.0-alpha.37",
51
+ "@lunora/errors": "1.0.0-alpha.6",
52
+ "@lunora/mail": "1.0.0-alpha.16",
53
+ "@lunora/server": "1.0.0-alpha.29"
52
54
  },
53
55
  "engines": {
54
56
  "node": "^22.15.0 || >=24.11.0"