@lunora/testing 1.0.0-alpha.5 → 1.0.0-alpha.50
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/LICENSE.md +6 -0
- package/README.md +12 -0
- package/__assets__/package-og.svg +1 -1
- package/dist/index.d.mts +370 -182
- package/dist/index.d.ts +370 -182
- package/dist/index.mjs +3 -1
- package/dist/packem_shared/agentHarness-GTPhWdQ8.mjs +144 -0
- package/dist/packem_shared/containsScorer-DGwtvUNp.mjs +94 -0
- package/dist/packem_shared/{lunoraTest-D8kUyDzR.mjs → lunoraTest-CHix6VWB.mjs} +135 -54
- package/package.json +7 -5
|
@@ -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 =
|
|
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,
|
|
31
|
-
const id = enqueue(nowMs + delayMs,
|
|
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,
|
|
35
|
-
const id = enqueue(timestampMs,
|
|
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
|
|
50
|
-
await dispatch(entry.kind, entry,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(()
|
|
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 (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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((
|
|
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
|
|
247
|
-
const runInMutationTransaction =
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
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
|
-
() =>
|
|
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
|
|
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
|
|
359
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.0-alpha.50",
|
|
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/
|
|
50
|
-
"@lunora/
|
|
51
|
-
"@lunora/
|
|
49
|
+
"@lunora/agent": "1.0.0-alpha.7",
|
|
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"
|