@lunora/testing 1.0.0-alpha.4 → 1.0.0-alpha.41

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,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, 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
  };
@@ -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,7 +145,7 @@ 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),
@@ -154,8 +161,9 @@ const noopLog = {
154
161
  const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
155
162
  const factory = (referenceOrInline, args) => {
156
163
  let done = false;
157
- let pendingResolve;
164
+ const pendingWaiters = [];
158
165
  let pendingResult;
166
+ let pendingError;
159
167
  let latestSeq = 0;
160
168
  let appliedSeq = 0;
161
169
  const runQuery = () => {
@@ -170,25 +178,46 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
170
178
  }
171
179
  appliedSeq = seq;
172
180
  const iterResult = { done: false, value };
173
- if (pendingResolve === void 0) {
181
+ if (pendingWaiters.length === 0) {
174
182
  pendingResult = iterResult;
183
+ pendingError = void 0;
175
184
  } else {
176
- const resolve = pendingResolve;
177
- pendingResolve = void 0;
178
185
  pendingResult = void 0;
179
- resolve(iterResult);
186
+ pendingError = void 0;
187
+ for (const waiter of pendingWaiters.splice(0)) {
188
+ waiter.resolve(iterResult);
189
+ }
190
+ }
191
+ };
192
+ const emitError = (seq, error) => {
193
+ if (seq < appliedSeq) {
194
+ return;
195
+ }
196
+ appliedSeq = seq;
197
+ if (pendingWaiters.length === 0) {
198
+ pendingError = { error };
199
+ pendingResult = void 0;
200
+ } else {
201
+ pendingResult = void 0;
202
+ pendingError = void 0;
203
+ for (const waiter of pendingWaiters.splice(0)) {
204
+ waiter.reject(error);
205
+ }
180
206
  }
181
207
  };
182
208
  const emitAt = (seq) => (value) => {
183
209
  emit(seq, value);
184
210
  };
211
+ const emitErrorAt = (seq) => (error) => {
212
+ emitError(seq, error);
213
+ };
185
214
  const listener = () => {
186
215
  if (done) {
187
216
  return;
188
217
  }
189
218
  latestSeq += 1;
190
219
  const seq = latestSeq;
191
- runQuery().then(emitAt(seq)).catch(() => void 0);
220
+ runQuery().then(emitAt(seq)).catch(emitErrorAt(seq));
192
221
  };
193
222
  mutationListeners.add(listener);
194
223
  const iterator = {
@@ -199,17 +228,29 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
199
228
  if (done) {
200
229
  return Promise.resolve({ done: true, value: void 0 });
201
230
  }
202
- if (pendingResult !== void 0 && appliedSeq === latestSeq) {
203
- const result = pendingResult;
204
- pendingResult = void 0;
205
- return Promise.resolve(result);
231
+ if (appliedSeq === latestSeq) {
232
+ if (pendingError !== void 0) {
233
+ const { error } = pendingError;
234
+ pendingError = void 0;
235
+ return Promise.reject(error);
236
+ }
237
+ if (pendingResult !== void 0) {
238
+ const result = pendingResult;
239
+ pendingResult = void 0;
240
+ return Promise.resolve(result);
241
+ }
206
242
  }
207
243
  if (appliedSeq < latestSeq) {
208
- return new Promise((resolve) => {
209
- pendingResolve = resolve;
244
+ return new Promise((resolve, reject) => {
245
+ pendingWaiters.push({ reject, resolve });
210
246
  });
211
247
  }
212
248
  return runQuery().then((value) => {
249
+ if (pendingError !== void 0) {
250
+ const { error } = pendingError;
251
+ pendingError = void 0;
252
+ throw error;
253
+ }
213
254
  if (pendingResult !== void 0) {
214
255
  const result = pendingResult;
215
256
  pendingResult = void 0;
@@ -221,15 +262,13 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
221
262
  return: () => {
222
263
  done = true;
223
264
  mutationListeners.delete(listener);
224
- if (pendingResolve !== void 0) {
225
- const resolve = pendingResolve;
226
- pendingResolve = void 0;
227
- resolve({ done: true, value: void 0 });
265
+ for (const waiter of pendingWaiters.splice(0)) {
266
+ waiter.resolve({ done: true, value: void 0 });
228
267
  }
229
268
  return Promise.resolve({ done: true, value: void 0 });
230
269
  }
231
270
  };
232
- runQuery().then(emitAt(0)).catch(() => void 0);
271
+ runQuery().then(emitAt(0)).catch(emitErrorAt(0));
233
272
  return iterator;
234
273
  };
235
274
  return factory;
@@ -243,26 +282,28 @@ const lunoraTest = (schema, options) => {
243
282
  const runner = sql.exec;
244
283
  runner.call(sql, statement);
245
284
  };
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) {
285
+ let mutationQueue = Promise.resolve();
286
+ const runInMutationTransaction = (function_) => {
287
+ const runTransaction = async () => {
288
+ execStatement("BEGIN");
258
289
  try {
259
- execStatement("ROLLBACK");
260
- } catch {
290
+ const result2 = await function_();
291
+ execStatement("COMMIT");
292
+ return result2;
293
+ } catch (error) {
294
+ try {
295
+ execStatement("ROLLBACK");
296
+ } catch {
297
+ }
298
+ throw error;
261
299
  }
262
- throw error;
263
- } finally {
264
- transactionDepth = 0;
265
- }
300
+ };
301
+ const result = mutationQueue.then(runTransaction);
302
+ mutationQueue = result.then(
303
+ () => void 0,
304
+ () => void 0
305
+ );
306
+ return result;
266
307
  };
267
308
  let closed = false;
268
309
  const closeDatabase = () => {
@@ -283,20 +324,28 @@ const lunoraTest = (schema, options) => {
283
324
  };
284
325
  let scheduledDispatchRef;
285
326
  let mutationContextRef;
327
+ const harnessNow = options?.now ?? Date.now();
286
328
  const { controls: schedulerControls, scheduler: fakeScheduler } = createFakeScheduler(
287
329
  () => {
288
330
  if (scheduledDispatchRef === void 0) {
289
- throw new Error("[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed");
331
+ throw new LunoraError(
332
+ "INTERNAL",
333
+ "[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed"
334
+ );
290
335
  }
291
336
  return scheduledDispatchRef;
292
337
  },
293
338
  () => {
294
339
  if (mutationContextRef === void 0) {
295
- throw new Error("[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed");
340
+ throw new LunoraError(
341
+ "INTERNAL",
342
+ "[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed"
343
+ );
296
344
  }
297
345
  return mutationContextRef;
298
346
  },
299
- () => functionRegistryMap
347
+ () => functionRegistryMap,
348
+ harnessNow
300
349
  );
301
350
  const makeHarness = (identity) => {
302
351
  const auth = {
@@ -308,21 +357,27 @@ const lunoraTest = (schema, options) => {
308
357
  const queryContext = {
309
358
  auth,
310
359
  db: database,
360
+ env: options?.env,
311
361
  log: noopLog,
362
+ now: harnessNow,
312
363
  // 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
313
364
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
365
+ secrets: stubProxy("secrets"),
314
366
  storage: stubProxy("storage"),
315
367
  vectors: stubProxy("vectors")
316
368
  };
317
369
  const mutationContext = {
318
370
  auth,
319
371
  db: database,
372
+ env: options?.env,
320
373
  log: noopLog,
374
+ now: harnessNow,
321
375
  // 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
322
376
  runMutation: (reference, args) => runInternal("mutation", reference, mutationContext, args),
323
377
  // 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
324
378
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
325
379
  scheduler: fakeScheduler,
380
+ secrets: stubProxy("secrets"),
326
381
  storage: stubProxy("storage"),
327
382
  vectors: stubProxy("vectors"),
328
383
  workflows: stubProxy("workflows")
@@ -331,9 +386,11 @@ const lunoraTest = (schema, options) => {
331
386
  const actionContext = {
332
387
  auth,
333
388
  db: database,
389
+ env: options?.env,
334
390
  // Use the injected fetch when provided; fall back to the v1 stub otherwise.
335
391
  fetch: options?.fetch ?? stubProxy("fetch"),
336
392
  log: noopLog,
393
+ now: harnessNow,
337
394
  // 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
338
395
  runAction: (reference, args) => runInternal("action", reference, actionContext, args),
339
396
  // 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
@@ -341,6 +398,7 @@ const lunoraTest = (schema, options) => {
341
398
  // 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
342
399
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
343
400
  scheduler: fakeScheduler,
401
+ secrets: stubProxy("secrets"),
344
402
  storage: stubProxy("storage"),
345
403
  vectors: stubProxy("vectors"),
346
404
  workflows: stubProxy("workflows")
@@ -348,11 +406,12 @@ const lunoraTest = (schema, options) => {
348
406
  const runRegistered = (expected, reference, context, args, allowInternal) => {
349
407
  const kind = registeredFunctionKind(reference);
350
408
  if (kind !== expected) {
351
- throw new Error(`expected a registered ${expected}, received a ${kind ?? "non-function"} reference`);
409
+ throw new LunoraError("INTERNAL", `expected a registered ${expected}, received a ${kind ?? "non-function"} reference`);
352
410
  }
353
411
  if (!allowInternal && registeredFunctionVisibility(reference) === "internal") {
354
- throw new Error(
355
- `"${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.`
412
+ throw new LunoraError(
413
+ "INTERNAL",
414
+ `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.`
356
415
  );
357
416
  }
358
417
  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.4",
3
+ "version": "1.0.0-alpha.41",
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.1"
49
+ "@lunora/agent": "1.0.0-alpha.1",
50
+ "@lunora/do": "1.0.0-alpha.29",
51
+ "@lunora/errors": "1.0.0-alpha.4",
52
+ "@lunora/mail": "1.0.0-alpha.14",
53
+ "@lunora/server": "1.0.0-alpha.24"
52
54
  },
53
55
  "engines": {
54
56
  "node": "^22.15.0 || >=24.11.0"