@lunora/testing 1.0.0-alpha.54 → 1.0.0-alpha.56

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.mjs CHANGED
@@ -1,5 +1 @@
1
- export { agentHarness, finalTurn, toolCallTurn } from './packem_shared/agentHarness-GTPhWdQ8.mjs';
2
- export { evaluationAttributes, recordEvaluation } from './packem_shared/evaluationAttributes-C08Q5MOX.mjs';
3
- export { lunoraTest } from './packem_shared/lunoraTest-Dv_w_v9z.mjs';
4
- export { containsScorer, evaluate, exactMatchScorer, keywordScorer, llmScorer, regexScorer, scoreSample } from './packem_shared/containsScorer-DGwtvUNp.mjs';
5
- export { extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
1
+ import{agentHarness as o,finalTurn as t,toolCallTurn as a}from"./packem_shared/agentHarness-0b923YYC.mjs";import{evaluationAttributes as c,recordEvaluation as n}from"./packem_shared/evaluationAttributes-GZvtFa1N.mjs";import{lunoraTest as u}from"./packem_shared/lunoraTest-BAkd1qwt.mjs";import{containsScorer as m,evaluate as p,exactMatchScorer as s,keywordScorer as f,llmScorer as S,regexScorer as d,scoreSample as v}from"./packem_shared/containsScorer-Dr5tjL-M.mjs";import{extractLink as T,listCapturedMail as g,waitForMail as k}from"@lunora/mail/testing";export{o as agentHarness,m as containsScorer,p as evaluate,c as evaluationAttributes,s as exactMatchScorer,T as extractLink,t as finalTurn,f as keywordScorer,g as listCapturedMail,S as llmScorer,u as lunoraTest,n as recordEvaluation,d as regexScorer,v as scoreSample,a as toolCallTurn,k as waitForMail};
@@ -0,0 +1 @@
1
+ import{runAgentLoop as w,DEFAULT_AGENT_FUNCTION_PATHS as g}from"@lunora/agent";const y=o=>{const e=[...o];return()=>{const a=e.shift();if(!a)throw new Error("agentHarness: scripted generate exhausted — the run took more turns than the script provided.");return Promise.resolve(a)}};class v{invoked=[];entries=new Map;async do(e,a){const i=this.entries.get(e);if(i)return i.output;this.invoked.push(e);const u=await a();return this.entries.set(e,{output:u}),u}async waitForEvent(e,a){return new Promise(()=>{})}}const T=o=>{const e=new Map,a=new Map,i=[],u=t=>{const s=t?.key,r=t?.instanceId,n=e.get(s);return n?(n.status="running",delete n.error,r!==void 0&&(n.instanceId=r),{created:!1}):(e.set(s,{agent:t?.agent,instanceId:r,key:s,messageCount:0,owner:t?.owner,status:"running",title:t?.title}),{created:!0})},p=t=>{const s=t?.threadKey,r=t?.messageKey,n=`${s}:${r}`,f=a.get(n);if(f)return{seq:f.seq};const l=e.get(s);if(!l)throw new Error(`agentHarness: append to unknown thread "${s}".`);const m=l.messageCount;return l.messageCount+=1,a.set(n,{...t,seq:m}),{seq:m}},h=t=>{const s=t?.key;return[...a.values()].filter(r=>r.threadKey===s).toSorted((r,n)=>r.seq-n.seq)},d=t=>{const s=e.get(t?.key);if(s)for(const[r,n]of Object.entries(t??{}))r!=="key"&&n!==void 0&&(s[r]=n)},c=new Map([[g.appendMessage,p],[g.ensureThread,u],[g.listMessages,h],[g.patchThread,d],...Object.entries(o)]);return{dispatches:i,messages:a,run:(t,s)=>{const r=t.__lunoraRef;i.push({args:s,path:r});const n=c.get(r);if(!n)throw new Error(`agentHarness: unstubbed dispatch "${r}" — pass a \`functions\` handler for it.`);return Promise.resolve(n(s))},threads:e}},q=(o,e)=>{const a=e.exportName??"agent",i=e.env??{LUNORA_TEST:!0},u=T(e.functions??{});let p=0;const h=(d,c)=>{p+=1;const t=c?.instanceId??`wf-${String(p)}`,s=c?.script??e.script;return w({agent:o,env:i,exportName:a,generate:y(s),instanceId:t,params:d,paths:g,run:u.run,step:new v})};return{dispatches:u.dispatches,messages:d=>[...u.messages.values()].filter(c=>c.threadKey===d).toSorted((c,t)=>c.seq-t.seq).map(({messageKey:c,threadKey:t,...s})=>s),run:h,thread:d=>u.threads.get(d)}},C=(o,e)=>({text:o,toolCalls:[],...e}),E=(o,e,a,i="")=>({text:i,toolCalls:[{id:o,input:a,name:e}]});export{q as agentHarness,C as finalTurn,E as toolCallTurn};
@@ -0,0 +1,2 @@
1
+ import{LunoraError as p}from"@lunora/errors";const d=/^\s*(-?\d+(?:\.\d+)?)/u,i=e=>Number.isFinite(e)?Math.min(1,Math.max(0,e)):0,l=e=>typeof e=="number"?{score:i(e)}:{score:i(e.score),...e.reason===void 0?{}:{reason:e.reason}},u=e=>e.length===0?0:e.reduce((t,r)=>t+r,0)/e.length,v=(e,t={})=>({name:`contains:${e}`,score:({output:r})=>{const s=t.caseSensitive?r:r.toLowerCase(),a=t.caseSensitive?e:e.toLowerCase();return s.includes(a)?1:0}}),x=(e,t="regex")=>({name:t,score:({output:r})=>e.test(r)?1:0}),y=()=>({name:"exact-match",score:({expected:e,output:t})=>t.trim()===e?.trim()?1:0}),f=e=>{if(e.length===0)throw new p("BAD_REQUEST","@lunora/testing: keywordScorer requires at least one keyword");return{name:"keyword-coverage",score:({output:t})=>{const r=t.toLowerCase(),s=e.filter(a=>r.includes(a.toLowerCase())).length;return{reason:`${String(s)}/${String(e.length)} keywords present`,score:s/e.length}}}},g=(e,t)=>[`Rate the ASSISTANT OUTPUT against this criterion: ${e}`,"Respond with a single number from 0 (fails) to 1 (fully meets), then a dash and a one-line reason.",...t.input===void 0?[]:["",`Input: ${t.input}`],...t.expected===void 0?[]:["",`Reference answer: ${t.expected}`],"",`Assistant output: ${t.output}`].join(`
2
+ `),w=e=>{const t=d.exec(e);return{reason:e.trim(),score:t?i(Number(t[1])):0}},$=e=>({name:e.name??"llm-judge",score:async t=>w(await e.judge(g(e.criteria,t)))}),h=async(e,t)=>{const r=await Promise.all(t.map(async o=>({name:o.name,result:l(await o.score(e))}))),s={},a=new Map;for(const{name:o,result:c}of r){const n=a.get(o)??0;a.set(o,n+1),s[n===0?o:`${o}#${String(n+1)}`]=c}return{average:u(r.map(({result:o})=>o.score)),scores:s}},b=async(e,t,r)=>{const s=await Promise.all(e.map(async a=>{const o=await t(a.input),c={input:a.input,output:o,...a.expected===void 0?{}:{expected:a.expected},...a.metadata===void 0?{}:{metadata:a.metadata}},{average:n,scores:m}=await h(c,r);return{average:n,input:a.input,output:o,scores:m}}));return{average:u(s.map(a=>a.average)),items:s}};export{v as containsScorer,b as evaluate,y as exactMatchScorer,f as keywordScorer,$ as llmScorer,x as regexScorer,h as scoreSample};
@@ -0,0 +1 @@
1
+ import{LunoraError as o}from"@lunora/errors";const a=/[\w.-]/u,t=e=>{let r="";for(const n of e)r+=a.test(n)?n:"_";return r},i=e=>{if(typeof e.name!="string"||e.name.length===0)throw new o("BAD_REQUEST","@lunora/testing: recordEvaluation requires a non-empty `name`");if(typeof e.score!="number"||!Number.isFinite(e.score))throw new o("BAD_REQUEST","@lunora/testing: recordEvaluation `score` must be a finite number");const r=t(e.name),n={[`gen_ai.evaluation.${r}.score`]:e.score};return e.label!==void 0&&(n[`gen_ai.evaluation.${r}.label`]=e.label),n},u=e=>{const r=i(e);return e.span?.setAttributes(r),r};export{i as evaluationAttributes,u as recordEvaluation};
@@ -0,0 +1 @@
1
+ import{runShardMigrations as z,createShardCtxDb as G}from"@lunora/do";import{LunoraError as j}from"@lunora/errors";import{DatabaseSync as H}from"node:sqlite";const K=(c,a,f,v,I)=>{let w=I,m=1;const i=new Map,u=[],p=(t,o,l={})=>{const e=`fake-job-${String(m)}`;return m+=1,i.set(e,{args:l,enqueuedAt:w,functionPath:o,id:e,scheduledFor:t}),e},y=t=>typeof t=="string"?t:t.name??t.binding??"",x={cancel:t=>{const o=i.has(t);return i.delete(t),Promise.resolve({cancelled:o})},get:t=>Promise.resolve(i.get(t)??null),list:()=>Promise.resolve([...i.values()]),runAfter:(t,o,l)=>{const e=p(w+t,y(o),l);return Promise.resolve(e)},runAt:(t,o,l)=>{const e=p(t,y(o),l);return Promise.resolve(e)}},q=async t=>{i.delete(t.id);const o=v().get(t.functionPath);if(o===void 0){console.warn(`[fake-scheduler] unknown functionPath "${t.functionPath}" — job ${t.id} dropped`);return}if(o.kind==="mutation"||o.kind==="action"){const l=c(),e=o.kind==="action"?f():a();await l(o.kind,o,e,t.args)}else console.warn(`[fake-scheduler] functionPath "${t.functionPath}" is a ${o.kind} — only mutations and actions can be scheduled; job ${t.id} dropped`)},N=async t=>{const o=[...i.values()].filter(n=>n.scheduledFor<=t).toSorted((n,d)=>n.scheduledFor-d.scheduledFor),l=[];let e=0;for(const n of o)if(i.has(n.id)){e+=1;try{await q(n)}catch(d){const h={args:n.args,error:d,functionPath:n.functionPath,id:n.id};l.push(h),u.push(h)}}return{executed:e,failed:l}},k=async(t,o)=>{const{executed:l,failed:e}=await N(t);if(e.length>0&&(o?.throwOnError??!0)){const[n]=e;throw e.length===1&&n!==void 0?n.error:new AggregateError(e.map(d=>d.error),`${String(e.length)} scheduled jobs failed: ${e.map(d=>d.functionPath).join(", ")}`)}return l};return{controls:{advance:(t,o)=>(w+=t,k(w,o)),failures:()=>[...u],list:()=>[...i.values()],runPending:t=>k(Number.POSITIVE_INFINITY,t)},scheduler:x}},U=()=>{const c=new H(":memory:"),a=f=>({one(){if(f.length!==1)throw new j("INTERNAL",`expected exactly one row, received ${String(f.length)}`);const[v]=f;return v},[Symbol.iterator](){return f[Symbol.iterator]()},toArray(){return f}});return{close:()=>{c.close()},sql:{exec:(f,...v)=>{const I=c.prepare(f).all(...v);return a(I)}}}},R=c=>{if(typeof c!="object"||c===null)return;const{kind:a}=c;if(a==="query"||a==="mutation"||a==="action")return a},W=c=>typeof c=="object"&&c!==null&&c.visibility==="internal"?"internal":"public",O=c=>{throw new j("INTERNAL",`ctx.${c} is not available in the in-memory @lunora/testing harness (v1)`)},g=c=>new Proxy((...a)=>O(c),{apply:()=>O(c),get:()=>O(c)}),X={setAttribute:()=>{},setAttributes:()=>{}},C=async(c,a)=>await a(C,X),Q={count:()=>{},gauge:()=>{},record:()=>{}},M={debug:()=>{},error:()=>{},fatal:()=>{},info:()=>{},log:()=>{},trace:()=>{},warn:()=>{},with:()=>M},Z=(c,a,f)=>(v,I)=>{let w=!1;const m=[];let i,u,p=0,y=0;const x=()=>R(v)?c("query",v,a,I,!1):Promise.resolve(v(a)),q=(e,n)=>{if(e<y)return;y=e;const d={done:!1,value:n};if(m.length===0)i=d,u=void 0;else{i=void 0,u=void 0;for(const h of m.splice(0))h.resolve(d)}},N=(e,n)=>{if(!(e<y))if(y=e,m.length===0)u={error:n},i=void 0;else{i=void 0,u=void 0;for(const d of m.splice(0))d.reject(n)}},k=e=>n=>{q(e,n)},t=e=>n=>{N(e,n)},o=()=>{if(w)return;p+=1;const e=p;x().then(k(e)).catch(t(e))};f.add(o);const l={[Symbol.asyncIterator](){return l},next:()=>{if(w)return Promise.resolve({done:!0,value:void 0});if(y===p){if(u!==void 0){const{error:e}=u;return u=void 0,Promise.reject(e)}if(i!==void 0){const e=i;return i=void 0,Promise.resolve(e)}}return y<p?new Promise((e,n)=>{m.push({reject:n,resolve:e})}):x().then(e=>{if(u!==void 0){const{error:n}=u;throw u=void 0,n}if(i!==void 0){const n=i;return i=void 0,n}return{done:!1,value:e}})},return:()=>{w=!0,f.delete(o);for(const e of m.splice(0))e.resolve({done:!0,value:void 0});return Promise.resolve({done:!0,value:void 0})}};return x().then(k(0)).catch(t(0)),l},re=(c,a)=>{const{close:f,sql:v}=U(),I=c;z(v,I);const w=G({schema:I,sql:v}),m=h=>{v.exec.call(v,h)};let i=Promise.resolve();const u=h=>{const $=async()=>{m("BEGIN");try{const b=await h();return m("COMMIT"),b}catch(b){try{m("ROLLBACK")}catch{}throw b}},A=i.then($);return i=A.then(()=>{},()=>{}),A};let p=!1;const y=()=>{p||(p=!0,f())},x=new Map(Object.entries(a?.functions??{}).map(([h,$])=>[h,$])),q=new Set,N=()=>{for(const h of q)h()};let k,t,o;const l=a?.now??Date.now(),{controls:e,scheduler:n}=K(()=>{if(k===void 0)throw new j("INTERNAL","[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed");return k},()=>{if(t===void 0)throw new j("INTERNAL","[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed");return t},()=>{if(o===void 0)throw new j("INTERNAL","[fake-scheduler] actionContext not yet available — scheduler.advance called before harness construction completed");return o},()=>x,l),d=h=>{const $={getIdentity:()=>Promise.resolve(h??null),userId:h?.userId??null},A={auth:$,db:w,env:a?.env,log:M,metrics:Q,now:l,trace:C,runQuery:(r,s)=>S("query",r,A,s),secrets:g("secrets"),storage:g("storage"),vectors:g("vectors")},b={auth:$,db:w,env:a?.env,log:M,metrics:Q,now:l,trace:C,runMutation:(r,s)=>S("mutation",r,b,s),runQuery:(r,s)=>S("query",r,A,s),scheduler:n,secrets:g("secrets"),storage:g("storage"),vectors:g("vectors"),workflows:g("workflows")};t??=b;const T={auth:$,db:w,env:a?.env,fetch:a?.fetch??g("fetch"),log:M,metrics:Q,now:l,trace:C,runAction:(r,s)=>S("action",r,T,s),runMutation:(r,s)=>S("mutation",r,b,s),runQuery:(r,s)=>S("query",r,A,s),scheduler:n,secrets:g("secrets"),storage:g("storage"),vectors:g("vectors"),workflows:g("workflows")};o??=T;const E=(r,s,P,L,F)=>{const D=R(s);if(D!==r)throw new j("INTERNAL",`expected a registered ${r}, received a ${D??"non-function"} reference`);if(!F&&W(s)==="internal")throw new j("INTERNAL",`This ${r} is an internal function — it is unreachable from the external RPC boundary in production. Call it through ctx.run${r.charAt(0).toUpperCase()}${r.slice(1)} from another function instead.`);return Promise.resolve(s.handler(P,L??{}))},S=(r,s,P,L)=>E(r,s,P,L,!0);k??=(r,s,P,L)=>r==="mutation"?u(()=>E("mutation",s,P,L,!0)).then(F=>(N(),F)):S("action",s,P,L);const B=((r,s)=>R(r)?E("query",r,A,s,!1):Promise.resolve(r(A))),V=((r,s)=>R(r)?u(()=>E("mutation",r,b,s,!1)).then(P=>(N(),P)):u(()=>r(b)).then(P=>(N(),P))),Y=((r,s)=>R(r)?E("action",r,T,s,!1):Promise.resolve(r(T))),_=Z(E,A,q);return{action:Y,close:y,mutation:V,query:B,run:r=>u(()=>r(b)).then(s=>(N(),s)),scheduler:e,subscribe:_,withIdentity:r=>d(r)}};return d(null)};export{re as lunoraTest};
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A Playwright test double for Lunora's browser wire protocol.
3
+ *
4
+ * An e2e suite for a Lunora app needs the app's data layer to behave — live
5
+ * queries resolve, shapes replicate, mutations ack and echo a watermark — without
6
+ * standing up a worker and a Durable Object per test. Doing that by hand means
7
+ * reimplementing the protocol from the outside: `page.route` on `/_lunora/rpc`, a
8
+ * `page.routeWebSocket` server that answers `subscribe` with `data` frames and
9
+ * `shape_subscribe` with a `pokeStart`/`pokePart`/`pokeEnd` triple, per-client
10
+ * watermark bookkeeping so optimistic overlays clear, and re-entrancy cleanup so a
11
+ * stacked handler from a previous `page.reload()` doesn't keep an old row store
12
+ * alive. Every adopter needs it, and every adopter gets the watermark bookkeeping
13
+ * subtly wrong — which then looks like a bug in Lunora.
14
+ *
15
+ * So it ships here, and doubles as an executable spec of the protocol.
16
+ *
17
+ * ```ts
18
+ * const lunora = await mockLunora(page, {
19
+ * rows: { nodes: [{ _id: "n1", text: "hello", userId: "u1" }] },
20
+ * shapes: { wholeOutline: { tables: ["nodes"] } },
21
+ * });
22
+ *
23
+ * await page.goto("/");
24
+ * await expect(page.getByText("hello")).toBeVisible();
25
+ *
26
+ * // Drive the server side from the test.
27
+ * await lunora.insert("nodes", { _id: "n2", text: "world", userId: "u1" });
28
+ * await expect(page.getByText("world")).toBeVisible();
29
+ *
30
+ * // Reproduce the failure modes that are otherwise impossible to hit on purpose.
31
+ * lunora.suppressPokes(); // a dropped poke → the client's checkpoint fallback
32
+ * lunora.failWrites("CONFLICT"); // a rejected mutation → rollback + error UI
33
+ * ```
34
+ *
35
+ * `@playwright/test` is a peer dependency and is imported **type-only**, so this
36
+ * module adds no runtime dependency for a project that never uses it.
37
+ */
38
+ /** The Playwright surface this double drives — declared structurally so the import stays type-only. */
39
+ interface MockablePage {
40
+ route: (url: string, handler: (route: MockRoute) => Promise<void> | void) => Promise<void>;
41
+ routeWebSocket: (url: RegExp | string, handler: (ws: MockWebSocketRoute) => void) => Promise<void>;
42
+ unroute: (url: string) => Promise<void>;
43
+ unrouteAll?: () => Promise<void>;
44
+ }
45
+ /** The slice of Playwright's `Route` used to answer an RPC POST. */
46
+ interface MockRoute {
47
+ fulfill: (response: {
48
+ body?: string;
49
+ contentType?: string;
50
+ status?: number;
51
+ }) => Promise<void>;
52
+ request: () => {
53
+ headers: () => Record<string, string>;
54
+ postData: () => null | string;
55
+ };
56
+ }
57
+ /** The slice of Playwright's `WebSocketRoute` used to serve frames. */
58
+ interface MockWebSocketRoute {
59
+ onMessage: (handler: (message: string) => void) => void;
60
+ send: (message: string) => void;
61
+ }
62
+ /** A replicated row. `_id` is the key the client indexes by. */
63
+ type MockRow = Record<string, unknown> & {
64
+ _id: string;
65
+ };
66
+ /** How a mocked shape selects rows from the seeded store. */
67
+ interface MockShape {
68
+ /** Tables whose rows this shape replicates. */
69
+ tables: ReadonlyArray<string>;
70
+ /**
71
+ * Narrow the shape to rows matching every entry. Values are compared with
72
+ * `===`, which covers the partition-selector case (`{ userId: "u1" }`).
73
+ */
74
+ where?: Record<string, unknown>;
75
+ }
76
+ /** Options for {@link mockLunora}. */
77
+ interface MockLunoraOptions {
78
+ /**
79
+ * Answers for non-mutator RPC calls, keyed by `namespace:fn`. A value may be a
80
+ * function of the call's args. Unlisted paths resolve to `undefined` — enough for a
81
+ * fire-and-forget mutation, and loud enough in a test that reads the result.
82
+ */
83
+ functions?: Record<string, unknown>;
84
+ /** Base path the app talks to. Defaults to `/_lunora`. */
85
+ path?: string;
86
+ /** Seeded rows per table. */
87
+ rows?: Record<string, ReadonlyArray<MockRow>>;
88
+ /** Shapes the app subscribes to, keyed by `defineShape` export name. */
89
+ shapes?: Record<string, MockShape>;
90
+ }
91
+ /** The handle {@link mockLunora} returns — drive the server side from the test. */
92
+ interface MockLunora {
93
+ /** Stop answering `/_lunora/*`. Also called implicitly by a later `mockLunora` on the same page. */
94
+ dispose: () => Promise<void>;
95
+ /** Reject every subsequent mutation/mutator call with `code`. Pass `false` to stop. */
96
+ failWrites: (code: false | string) => void;
97
+ /** Insert (or replace) a row and poke every shape that now matches it. */
98
+ insert: (table: string, row: MockRow) => Promise<void>;
99
+ /** Patch a row and poke the shapes replicating it. */
100
+ patch: (table: string, id: string, patch: Record<string, unknown>) => Promise<void>;
101
+ /** Delete a row and poke the shapes replicating it. */
102
+ remove: (table: string, id: string) => Promise<void>;
103
+ /** Re-send every subscribed shape's full rowset — what a reconnect would do. */
104
+ resync: () => Promise<void>;
105
+ /** Current server-side rows for `table`. */
106
+ rows: (table: string) => MockRow[];
107
+ /**
108
+ * Stop sending pokes while still accepting writes — the dropped-poke failure
109
+ * mode. Writes still ack and advance the watermark, so this exercises the
110
+ * client's checkpoint fallback rather than a dead connection. Pass `false` to
111
+ * resume (which does NOT replay the missed pokes; call `resync()` for that).
112
+ */
113
+ suppressPokes: (suppressed?: boolean) => void;
114
+ /** Highest `clientSeq` acked, per client id — the watermark the client is gating overlays on. */
115
+ watermarks: () => Record<string, number>;
116
+ }
117
+ /**
118
+ * Install the double on `page`. Returns a handle for driving the server side.
119
+ *
120
+ * Idempotent per page: calling it again disposes the previous installation first.
121
+ * That matters more than it sounds — a stacked `routeWebSocket` handler from before
122
+ * a `page.reload()` keeps serving from its own closed-over row store, so the app
123
+ * silently reads stale data and the test fails somewhere unrelated.
124
+ */
125
+ declare const mockLunora: (page: MockablePage, options?: MockLunoraOptions) => Promise<MockLunora>;
126
+ export { type MockLunora, type MockLunoraOptions, type MockRoute, type MockRow, type MockShape, type MockWebSocketRoute, type MockablePage, mockLunora };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A Playwright test double for Lunora's browser wire protocol.
3
+ *
4
+ * An e2e suite for a Lunora app needs the app's data layer to behave — live
5
+ * queries resolve, shapes replicate, mutations ack and echo a watermark — without
6
+ * standing up a worker and a Durable Object per test. Doing that by hand means
7
+ * reimplementing the protocol from the outside: `page.route` on `/_lunora/rpc`, a
8
+ * `page.routeWebSocket` server that answers `subscribe` with `data` frames and
9
+ * `shape_subscribe` with a `pokeStart`/`pokePart`/`pokeEnd` triple, per-client
10
+ * watermark bookkeeping so optimistic overlays clear, and re-entrancy cleanup so a
11
+ * stacked handler from a previous `page.reload()` doesn't keep an old row store
12
+ * alive. Every adopter needs it, and every adopter gets the watermark bookkeeping
13
+ * subtly wrong — which then looks like a bug in Lunora.
14
+ *
15
+ * So it ships here, and doubles as an executable spec of the protocol.
16
+ *
17
+ * ```ts
18
+ * const lunora = await mockLunora(page, {
19
+ * rows: { nodes: [{ _id: "n1", text: "hello", userId: "u1" }] },
20
+ * shapes: { wholeOutline: { tables: ["nodes"] } },
21
+ * });
22
+ *
23
+ * await page.goto("/");
24
+ * await expect(page.getByText("hello")).toBeVisible();
25
+ *
26
+ * // Drive the server side from the test.
27
+ * await lunora.insert("nodes", { _id: "n2", text: "world", userId: "u1" });
28
+ * await expect(page.getByText("world")).toBeVisible();
29
+ *
30
+ * // Reproduce the failure modes that are otherwise impossible to hit on purpose.
31
+ * lunora.suppressPokes(); // a dropped poke → the client's checkpoint fallback
32
+ * lunora.failWrites("CONFLICT"); // a rejected mutation → rollback + error UI
33
+ * ```
34
+ *
35
+ * `@playwright/test` is a peer dependency and is imported **type-only**, so this
36
+ * module adds no runtime dependency for a project that never uses it.
37
+ */
38
+ /** The Playwright surface this double drives — declared structurally so the import stays type-only. */
39
+ interface MockablePage {
40
+ route: (url: string, handler: (route: MockRoute) => Promise<void> | void) => Promise<void>;
41
+ routeWebSocket: (url: RegExp | string, handler: (ws: MockWebSocketRoute) => void) => Promise<void>;
42
+ unroute: (url: string) => Promise<void>;
43
+ unrouteAll?: () => Promise<void>;
44
+ }
45
+ /** The slice of Playwright's `Route` used to answer an RPC POST. */
46
+ interface MockRoute {
47
+ fulfill: (response: {
48
+ body?: string;
49
+ contentType?: string;
50
+ status?: number;
51
+ }) => Promise<void>;
52
+ request: () => {
53
+ headers: () => Record<string, string>;
54
+ postData: () => null | string;
55
+ };
56
+ }
57
+ /** The slice of Playwright's `WebSocketRoute` used to serve frames. */
58
+ interface MockWebSocketRoute {
59
+ onMessage: (handler: (message: string) => void) => void;
60
+ send: (message: string) => void;
61
+ }
62
+ /** A replicated row. `_id` is the key the client indexes by. */
63
+ type MockRow = Record<string, unknown> & {
64
+ _id: string;
65
+ };
66
+ /** How a mocked shape selects rows from the seeded store. */
67
+ interface MockShape {
68
+ /** Tables whose rows this shape replicates. */
69
+ tables: ReadonlyArray<string>;
70
+ /**
71
+ * Narrow the shape to rows matching every entry. Values are compared with
72
+ * `===`, which covers the partition-selector case (`{ userId: "u1" }`).
73
+ */
74
+ where?: Record<string, unknown>;
75
+ }
76
+ /** Options for {@link mockLunora}. */
77
+ interface MockLunoraOptions {
78
+ /**
79
+ * Answers for non-mutator RPC calls, keyed by `namespace:fn`. A value may be a
80
+ * function of the call's args. Unlisted paths resolve to `undefined` — enough for a
81
+ * fire-and-forget mutation, and loud enough in a test that reads the result.
82
+ */
83
+ functions?: Record<string, unknown>;
84
+ /** Base path the app talks to. Defaults to `/_lunora`. */
85
+ path?: string;
86
+ /** Seeded rows per table. */
87
+ rows?: Record<string, ReadonlyArray<MockRow>>;
88
+ /** Shapes the app subscribes to, keyed by `defineShape` export name. */
89
+ shapes?: Record<string, MockShape>;
90
+ }
91
+ /** The handle {@link mockLunora} returns — drive the server side from the test. */
92
+ interface MockLunora {
93
+ /** Stop answering `/_lunora/*`. Also called implicitly by a later `mockLunora` on the same page. */
94
+ dispose: () => Promise<void>;
95
+ /** Reject every subsequent mutation/mutator call with `code`. Pass `false` to stop. */
96
+ failWrites: (code: false | string) => void;
97
+ /** Insert (or replace) a row and poke every shape that now matches it. */
98
+ insert: (table: string, row: MockRow) => Promise<void>;
99
+ /** Patch a row and poke the shapes replicating it. */
100
+ patch: (table: string, id: string, patch: Record<string, unknown>) => Promise<void>;
101
+ /** Delete a row and poke the shapes replicating it. */
102
+ remove: (table: string, id: string) => Promise<void>;
103
+ /** Re-send every subscribed shape's full rowset — what a reconnect would do. */
104
+ resync: () => Promise<void>;
105
+ /** Current server-side rows for `table`. */
106
+ rows: (table: string) => MockRow[];
107
+ /**
108
+ * Stop sending pokes while still accepting writes — the dropped-poke failure
109
+ * mode. Writes still ack and advance the watermark, so this exercises the
110
+ * client's checkpoint fallback rather than a dead connection. Pass `false` to
111
+ * resume (which does NOT replay the missed pokes; call `resync()` for that).
112
+ */
113
+ suppressPokes: (suppressed?: boolean) => void;
114
+ /** Highest `clientSeq` acked, per client id — the watermark the client is gating overlays on. */
115
+ watermarks: () => Record<string, number>;
116
+ }
117
+ /**
118
+ * Install the double on `page`. Returns a handle for driving the server side.
119
+ *
120
+ * Idempotent per page: calling it again disposes the previous installation first.
121
+ * That matters more than it sounds — a stacked `routeWebSocket` handler from before
122
+ * a `page.reload()` keeps serving from its own closed-over row store, so the app
123
+ * silently reads stale data and the test fails somewhere unrelated.
124
+ */
125
+ declare const mockLunora: (page: MockablePage, options?: MockLunoraOptions) => Promise<MockLunora>;
126
+ export { type MockLunora, type MockLunoraOptions, type MockRoute, type MockRow, type MockShape, type MockWebSocketRoute, type MockablePage, mockLunora };
@@ -0,0 +1 @@
1
+ const E=(c,p)=>p?Object.entries(p).every(([l,w])=>c[l]===w):!0,D=async(c,p={})=>{const l=p.path??"/_lunora",w=`${l}/rpc`,J=`${l}/rpc-batch`,d=p.shapes??{},M=p.functions??{},m=new Map;for(const[e,t]of Object.entries(p.rows??{}))m.set(e,new Map(t.map(a=>[a._id,{...a}])));const f=new Map,i=new Map,b=new Map;let u=0,_=0,O=!1,g=!1,j=!1;const $=new Set,P=async()=>{await c.unroute(w),await c.unroute(J)};await P();const y=e=>{const t=m.get(e);if(t)return t;const a=new Map;return m.set(e,a),a},T=e=>{const t=d[e];if(!t)return[];const a=[];for(const n of t.tables)for(const s of y(n).values())E(s,t.where)&&a.push({row:s,table:n});return a},S=()=>Math.max(0,...f.values()),x=e=>{if(O||e.length===0)return;_+=1,u+=1;const t=`poke_${String(_)}`,a=S();for(const{shapeId:n}of e){const s=i.get(n);s&&s.send(JSON.stringify({baseCheckpoint:u-1,pokeId:t,type:"pokeStart"}))}for(const{ops:n,shapeId:s}of e){const o=i.get(s);o&&o.send(JSON.stringify({lastMutationId:a,pokeId:t,rowsPatch:n,shapeId:s,type:"pokePart"}))}for(const{shapeId:n}of e){const s=i.get(n);s&&s.send(JSON.stringify({checkpoint:u,pokeId:t,type:"pokeEnd"}))}},N=(e,t,a,n)=>{const s=[];for(const o of i.values()){const r=d[o.name];if(!r?.tables.includes(e))continue;const h=t!=="delete"&&n!==void 0&&!E(n,r.where)?"delete":t;s.push({ops:[{key:a,op:h,table:e,...h==="delete"?{}:{value:n}}],shapeId:o.id})}x(s)},L=e=>{const t=T(e.name).map(({row:a,table:n})=>({key:a._id,op:"insert",table:n,value:a}));x([{ops:t,shapeId:e.id}])},k=()=>{if(O)return;const e=S();for(const t of b.values())t.send(JSON.stringify({cursor:u,id:t.id,lastMutationId:e,type:"settled"}))},q=async e=>{const t=e.request(),a=t.postData();let n={};if(typeof a=="string")try{n=JSON.parse(a)}catch{await e.fulfill({body:JSON.stringify({error:{code:"BAD_REQUEST",message:"mockLunora: unparseable RPC body"}}),contentType:"application/json",status:400});return}const s=n.functionPath??"",o=t.headers(),r=o["x-lunora-client-id"]??"",h=Number.parseInt(o["x-lunora-client-seq"]??"",10),v=r!==""&&Number.isFinite(h);if(g!==!1&&v){await e.fulfill({body:JSON.stringify({error:{code:g,message:`mockLunora: writes are failing with ${g}`}}),contentType:"application/json",status:400});return}v&&f.set(r,Math.max(f.get(r)??0,h));const I=M[s],C=typeof I=="function"?I(n.args??{}):I;await e.fulfill({body:JSON.stringify({commitCursor:u,lastMutationId:v?f.get(r)??0:S(),result:C}),contentType:"application/json",status:200})};return await c.route(w,q),await c.route(J,q),await c.routeWebSocket(`${l}/ws`,e=>{const t=a=>{j||e.send(a)};e.onMessage(a=>{let n;try{n=JSON.parse(a)}catch{return}const s=n.id??"";switch(n.type){case"shape_subscribe":{const o=n.shape?.name??"",r={id:s,name:o,send:t};d[o]===void 0&&!$.has(o)&&($.add(o),console.warn(`[mockLunora] the app subscribed to shape "${o}", which is not in \`shapes\` — it will replicate nothing and never poke. Declared: ${Object.keys(d).length===0?"(none)":Object.keys(d).join(", ")}.`)),i.set(s,r),t(JSON.stringify({id:s,type:"ack"})),L(r);return}case"shape_unsubscribe":{i.delete(s);return}case"subscribe":{b.set(s,{functionPath:n.query?.functionPath??"",id:s,send:t}),t(JSON.stringify({id:s,type:"ack"}));const o=M[n.query?.functionPath??""],r=typeof o=="function"?o({}):o??[];t(JSON.stringify({cursor:u,data:r,id:s,type:"data"}));return}case"unsubscribe":{b.delete(s);break}}})}),{dispose:async()=>{j=!0,i.clear(),b.clear(),await P()},failWrites:e=>{g=e},insert:async(e,t)=>{y(e).set(t._id,{...t}),N(e,"insert",t._id,{...t}),k()},patch:async(e,t,a)=>{const n=y(e),s=n.get(t);if(!s)throw new Error(`mockLunora.patch: no row "${t}" in table "${e}"`);const o={...s,...a,_id:t};n.set(t,o),N(e,"update",t,o),k()},remove:async(e,t)=>{y(e).delete(t),N(e,"delete",t),k()},resync:async()=>{for(const e of i.values())L(e);k()},rows:e=>[...y(e).values()].map(t=>({...t})),suppressPokes:(e=!0)=>{O=e},watermarks:()=>Object.fromEntries(f)}};export{D as mockLunora};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/testing",
3
- "version": "1.0.0-alpha.54",
3
+ "version": "1.0.0-alpha.56",
4
4
  "description": "Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -40,17 +40,29 @@
40
40
  "types": "./dist/index.d.ts",
41
41
  "import": "./dist/index.mjs"
42
42
  },
43
+ "./playwright": {
44
+ "types": "./dist/playwright.d.ts",
45
+ "import": "./dist/playwright.mjs"
46
+ },
43
47
  "./package.json": "./package.json"
44
48
  },
45
49
  "publishConfig": {
46
50
  "access": "public"
47
51
  },
48
52
  "dependencies": {
49
- "@lunora/agent": "1.0.0-alpha.9",
50
- "@lunora/do": "1.0.0-alpha.43",
51
- "@lunora/errors": "1.0.0-alpha.7",
52
- "@lunora/mail": "1.0.0-alpha.17",
53
- "@lunora/server": "1.0.0-alpha.30"
53
+ "@lunora/agent": "1.0.0-alpha.11",
54
+ "@lunora/do": "1.0.0-alpha.45",
55
+ "@lunora/errors": "1.0.0-alpha.8",
56
+ "@lunora/mail": "1.0.0-alpha.18",
57
+ "@lunora/server": "1.0.0-alpha.32"
58
+ },
59
+ "peerDependencies": {
60
+ "@playwright/test": "^1.61.1"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@playwright/test": {
64
+ "optional": true
65
+ }
54
66
  },
55
67
  "engines": {
56
68
  "node": "^22.15.0 || >=24.11.0"
@@ -1,144 +0,0 @@
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 };
@@ -1,94 +0,0 @@
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,31 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
-
3
- const SAFE_NAME_CHAR = /[\w.-]/u;
4
- const sanitizeName = (name) => {
5
- let out = "";
6
- for (const char of name) {
7
- out += SAFE_NAME_CHAR.test(char) ? char : "_";
8
- }
9
- return out;
10
- };
11
- const evaluationAttributes = (input) => {
12
- if (typeof input.name !== "string" || input.name.length === 0) {
13
- throw new LunoraError("BAD_REQUEST", "@lunora/testing: recordEvaluation requires a non-empty `name`");
14
- }
15
- if (typeof input.score !== "number" || !Number.isFinite(input.score)) {
16
- throw new LunoraError("BAD_REQUEST", "@lunora/testing: recordEvaluation `score` must be a finite number");
17
- }
18
- const key = sanitizeName(input.name);
19
- const attributes = { [`gen_ai.evaluation.${key}.score`]: input.score };
20
- if (input.label !== void 0) {
21
- attributes[`gen_ai.evaluation.${key}.label`] = input.label;
22
- }
23
- return attributes;
24
- };
25
- const recordEvaluation = (input) => {
26
- const attributes = evaluationAttributes(input);
27
- input.span?.setAttributes(attributes);
28
- return attributes;
29
- };
30
-
31
- export { evaluationAttributes, recordEvaluation };
@@ -1,504 +0,0 @@
1
- import { runShardMigrations, createShardCtxDb } from '@lunora/do';
2
- import { LunoraError } from '@lunora/errors';
3
- import { DatabaseSync } from 'node:sqlite';
4
-
5
- const createFakeScheduler = (getDispatch, getMutationContext, getActionContext, getFunctionRegistry, now) => {
6
- let nowMs = now;
7
- let nextId = 1;
8
- const pending = /* @__PURE__ */ new Map();
9
- const recordedFailures = [];
10
- const enqueue = (scheduledFor, functionPath, args = {}) => {
11
- const id = `fake-job-${String(nextId)}`;
12
- nextId += 1;
13
- pending.set(id, {
14
- args,
15
- enqueuedAt: nowMs,
16
- functionPath,
17
- id,
18
- scheduledFor
19
- });
20
- return id;
21
- };
22
- const targetPath = (target) => typeof target === "string" ? target : target.name ?? target.binding ?? "";
23
- const scheduler = {
24
- cancel: (id) => {
25
- const existed = pending.has(id);
26
- pending.delete(id);
27
- return Promise.resolve({ cancelled: existed });
28
- },
29
- // eslint-disable-next-line unicorn/no-null -- Scheduler.get returns null when absent (public API contract)
30
- get: (id) => Promise.resolve(pending.get(id) ?? null),
31
- list: () => Promise.resolve([...pending.values()]),
32
- runAfter: (delayMs, target, args) => {
33
- const id = enqueue(nowMs + delayMs, targetPath(target), args);
34
- return Promise.resolve(id);
35
- },
36
- runAt: (timestampMs, target, args) => {
37
- const id = enqueue(timestampMs, targetPath(target), args);
38
- return Promise.resolve(id);
39
- }
40
- };
41
- const dispatchJob = async (job) => {
42
- pending.delete(job.id);
43
- const registry = getFunctionRegistry();
44
- const entry = registry.get(job.functionPath);
45
- if (entry === void 0) {
46
- console.warn(`[fake-scheduler] unknown functionPath "${job.functionPath}" — job ${job.id} dropped`);
47
- return;
48
- }
49
- if (entry.kind === "mutation" || entry.kind === "action") {
50
- const dispatch = getDispatch();
51
- const context = entry.kind === "action" ? getActionContext() : getMutationContext();
52
- await dispatch(entry.kind, entry, context, job.args);
53
- } else {
54
- console.warn(
55
- `[fake-scheduler] functionPath "${job.functionPath}" is a ${entry.kind} — only mutations and actions can be scheduled; job ${job.id} dropped`
56
- );
57
- }
58
- };
59
- const executeDue = async (cutoff) => {
60
- const due = [...pending.values()].filter((j) => j.scheduledFor <= cutoff).toSorted((a, b) => a.scheduledFor - b.scheduledFor);
61
- const failed = [];
62
- let executed = 0;
63
- for (const job of due) {
64
- if (!pending.has(job.id)) {
65
- continue;
66
- }
67
- executed += 1;
68
- try {
69
- await dispatchJob(job);
70
- } catch (error) {
71
- const failure = { args: job.args, error, functionPath: job.functionPath, id: job.id };
72
- failed.push(failure);
73
- recordedFailures.push(failure);
74
- }
75
- }
76
- return { executed, failed };
77
- };
78
- const runSweep = async (cutoff, options) => {
79
- const { executed, failed } = await executeDue(cutoff);
80
- if (failed.length > 0 && (options?.throwOnError ?? true)) {
81
- const [first] = failed;
82
- if (failed.length === 1 && first !== void 0) {
83
- throw first.error;
84
- }
85
- throw new AggregateError(
86
- failed.map((f) => f.error),
87
- `${String(failed.length)} scheduled jobs failed: ${failed.map((f) => f.functionPath).join(", ")}`
88
- );
89
- }
90
- return executed;
91
- };
92
- const controls = {
93
- advance: (ms, options) => {
94
- nowMs += ms;
95
- return runSweep(nowMs, options);
96
- },
97
- failures: () => [...recordedFailures],
98
- list: () => [...pending.values()],
99
- runPending: (options) => runSweep(Number.POSITIVE_INFINITY, options)
100
- };
101
- return { controls, scheduler };
102
- };
103
-
104
- const createSqlExec = () => {
105
- const database = new DatabaseSync(":memory:");
106
- const cursor = (rows) => {
107
- return {
108
- one() {
109
- if (rows.length !== 1) {
110
- throw new LunoraError("INTERNAL", `expected exactly one row, received ${String(rows.length)}`);
111
- }
112
- const [only] = rows;
113
- return only;
114
- },
115
- [Symbol.iterator]() {
116
- return rows[Symbol.iterator]();
117
- },
118
- toArray() {
119
- return rows;
120
- }
121
- };
122
- };
123
- const run = (query, ...params) => {
124
- const statement = database.prepare(query);
125
- const rows = statement.all(...params);
126
- return cursor(rows);
127
- };
128
- return {
129
- close: () => {
130
- database.close();
131
- },
132
- sql: { exec: run }
133
- };
134
- };
135
-
136
- const registeredFunctionKind = (value) => {
137
- if (typeof value !== "object" || value === null) {
138
- return void 0;
139
- }
140
- const { kind } = value;
141
- if (kind === "query" || kind === "mutation" || kind === "action") {
142
- return kind;
143
- }
144
- return void 0;
145
- };
146
- const registeredFunctionVisibility = (value) => typeof value === "object" && value !== null && value.visibility === "internal" ? "internal" : "public";
147
- const unavailable = (surface) => {
148
- throw new LunoraError("INTERNAL", `ctx.${surface} is not available in the in-memory @lunora/testing harness (v1)`);
149
- };
150
- const stubProxy = (surface) => /* @__PURE__ */ new Proxy((..._args) => unavailable(surface), {
151
- apply: () => unavailable(surface),
152
- get: () => unavailable(surface)
153
- });
154
- const noopSpan = {
155
- setAttribute: () => void 0,
156
- setAttributes: () => void 0
157
- };
158
- const passthroughTrace = async (_name, function_) => await function_(passthroughTrace, noopSpan);
159
- const noopMetrics = {
160
- count: () => void 0,
161
- gauge: () => void 0,
162
- record: () => void 0
163
- };
164
- const noopLog = {
165
- debug: () => void 0,
166
- error: () => void 0,
167
- fatal: () => void 0,
168
- info: () => void 0,
169
- log: () => void 0,
170
- trace: () => void 0,
171
- warn: () => void 0,
172
- with: () => noopLog
173
- };
174
- const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
175
- const factory = (referenceOrInline, args) => {
176
- let done = false;
177
- const pendingWaiters = [];
178
- let pendingResult;
179
- let pendingError;
180
- let latestSeq = 0;
181
- let appliedSeq = 0;
182
- const runQuery = () => {
183
- if (registeredFunctionKind(referenceOrInline)) {
184
- return runRegistered("query", referenceOrInline, queryContext, args, false);
185
- }
186
- return Promise.resolve(referenceOrInline(queryContext));
187
- };
188
- const emit = (seq, value) => {
189
- if (seq < appliedSeq) {
190
- return;
191
- }
192
- appliedSeq = seq;
193
- const iterResult = { done: false, value };
194
- if (pendingWaiters.length === 0) {
195
- pendingResult = iterResult;
196
- pendingError = void 0;
197
- } else {
198
- pendingResult = void 0;
199
- pendingError = void 0;
200
- for (const waiter of pendingWaiters.splice(0)) {
201
- waiter.resolve(iterResult);
202
- }
203
- }
204
- };
205
- const emitError = (seq, error) => {
206
- if (seq < appliedSeq) {
207
- return;
208
- }
209
- appliedSeq = seq;
210
- if (pendingWaiters.length === 0) {
211
- pendingError = { error };
212
- pendingResult = void 0;
213
- } else {
214
- pendingResult = void 0;
215
- pendingError = void 0;
216
- for (const waiter of pendingWaiters.splice(0)) {
217
- waiter.reject(error);
218
- }
219
- }
220
- };
221
- const emitAt = (seq) => (value) => {
222
- emit(seq, value);
223
- };
224
- const emitErrorAt = (seq) => (error) => {
225
- emitError(seq, error);
226
- };
227
- const listener = () => {
228
- if (done) {
229
- return;
230
- }
231
- latestSeq += 1;
232
- const seq = latestSeq;
233
- runQuery().then(emitAt(seq)).catch(emitErrorAt(seq));
234
- };
235
- mutationListeners.add(listener);
236
- const iterator = {
237
- [Symbol.asyncIterator]() {
238
- return iterator;
239
- },
240
- next: () => {
241
- if (done) {
242
- return Promise.resolve({ done: true, value: void 0 });
243
- }
244
- if (appliedSeq === latestSeq) {
245
- if (pendingError !== void 0) {
246
- const { error } = pendingError;
247
- pendingError = void 0;
248
- return Promise.reject(error);
249
- }
250
- if (pendingResult !== void 0) {
251
- const result = pendingResult;
252
- pendingResult = void 0;
253
- return Promise.resolve(result);
254
- }
255
- }
256
- if (appliedSeq < latestSeq) {
257
- return new Promise((resolve, reject) => {
258
- pendingWaiters.push({ reject, resolve });
259
- });
260
- }
261
- return runQuery().then((value) => {
262
- if (pendingError !== void 0) {
263
- const { error } = pendingError;
264
- pendingError = void 0;
265
- throw error;
266
- }
267
- if (pendingResult !== void 0) {
268
- const result = pendingResult;
269
- pendingResult = void 0;
270
- return result;
271
- }
272
- return { done: false, value };
273
- });
274
- },
275
- return: () => {
276
- done = true;
277
- mutationListeners.delete(listener);
278
- for (const waiter of pendingWaiters.splice(0)) {
279
- waiter.resolve({ done: true, value: void 0 });
280
- }
281
- return Promise.resolve({ done: true, value: void 0 });
282
- }
283
- };
284
- runQuery().then(emitAt(0)).catch(emitErrorAt(0));
285
- return iterator;
286
- };
287
- return factory;
288
- };
289
- const lunoraTest = (schema, options) => {
290
- const { close, sql } = createSqlExec();
291
- const ddlSchema = schema;
292
- runShardMigrations(sql, ddlSchema);
293
- const database = createShardCtxDb({ schema: ddlSchema, sql });
294
- const execStatement = (statement) => {
295
- const runner = sql.exec;
296
- runner.call(sql, statement);
297
- };
298
- let mutationQueue = Promise.resolve();
299
- const runInMutationTransaction = (function_) => {
300
- const runTransaction = async () => {
301
- execStatement("BEGIN");
302
- try {
303
- const result2 = await function_();
304
- execStatement("COMMIT");
305
- return result2;
306
- } catch (error) {
307
- try {
308
- execStatement("ROLLBACK");
309
- } catch {
310
- }
311
- throw error;
312
- }
313
- };
314
- const result = mutationQueue.then(runTransaction);
315
- mutationQueue = result.then(
316
- () => void 0,
317
- () => void 0
318
- );
319
- return result;
320
- };
321
- let closed = false;
322
- const closeDatabase = () => {
323
- if (closed) {
324
- return;
325
- }
326
- closed = true;
327
- close();
328
- };
329
- const functionRegistryMap = new Map(
330
- Object.entries(options?.functions ?? {}).map(([path, function_]) => [path, function_])
331
- );
332
- const mutationListeners = /* @__PURE__ */ new Set();
333
- const notifyMutationListeners = () => {
334
- for (const listener of mutationListeners) {
335
- listener();
336
- }
337
- };
338
- let scheduledDispatchRef;
339
- let mutationContextRef;
340
- let actionContextRef;
341
- const harnessNow = options?.now ?? Date.now();
342
- const { controls: schedulerControls, scheduler: fakeScheduler } = createFakeScheduler(
343
- () => {
344
- if (scheduledDispatchRef === void 0) {
345
- throw new LunoraError(
346
- "INTERNAL",
347
- "[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed"
348
- );
349
- }
350
- return scheduledDispatchRef;
351
- },
352
- () => {
353
- if (mutationContextRef === void 0) {
354
- throw new LunoraError(
355
- "INTERNAL",
356
- "[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed"
357
- );
358
- }
359
- return mutationContextRef;
360
- },
361
- () => {
362
- if (actionContextRef === void 0) {
363
- throw new LunoraError(
364
- "INTERNAL",
365
- "[fake-scheduler] actionContext not yet available — scheduler.advance called before harness construction completed"
366
- );
367
- }
368
- return actionContextRef;
369
- },
370
- () => functionRegistryMap,
371
- harnessNow
372
- );
373
- const makeHarness = (identity) => {
374
- const auth = {
375
- // eslint-disable-next-line unicorn/no-null -- AuthState.getIdentity's anonymous sentinel is `null` (mirrors a decoded JWT being absent)
376
- getIdentity: () => Promise.resolve(identity ?? null),
377
- // eslint-disable-next-line unicorn/no-null -- AuthState.userId's anonymous sentinel is `null`
378
- userId: identity?.userId ?? null
379
- };
380
- const queryContext = {
381
- auth,
382
- db: database,
383
- env: options?.env,
384
- log: noopLog,
385
- metrics: noopMetrics,
386
- now: harnessNow,
387
- trace: passthroughTrace,
388
- // 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
389
- runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
390
- secrets: stubProxy("secrets"),
391
- storage: stubProxy("storage"),
392
- vectors: stubProxy("vectors")
393
- };
394
- const mutationContext = {
395
- auth,
396
- db: database,
397
- env: options?.env,
398
- log: noopLog,
399
- metrics: noopMetrics,
400
- now: harnessNow,
401
- trace: passthroughTrace,
402
- // 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
403
- runMutation: (reference, args) => runInternal("mutation", reference, mutationContext, args),
404
- // 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
405
- runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
406
- scheduler: fakeScheduler,
407
- secrets: stubProxy("secrets"),
408
- storage: stubProxy("storage"),
409
- vectors: stubProxy("vectors"),
410
- workflows: stubProxy("workflows")
411
- };
412
- mutationContextRef ??= mutationContext;
413
- const actionContext = {
414
- auth,
415
- db: database,
416
- env: options?.env,
417
- // Use the injected fetch when provided; fall back to the v1 stub otherwise.
418
- fetch: options?.fetch ?? stubProxy("fetch"),
419
- log: noopLog,
420
- metrics: noopMetrics,
421
- now: harnessNow,
422
- trace: passthroughTrace,
423
- // 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
424
- runAction: (reference, args) => runInternal("action", reference, actionContext, args),
425
- // 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
426
- runMutation: (reference, args) => runInternal("mutation", reference, mutationContext, args),
427
- // 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
428
- runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
429
- scheduler: fakeScheduler,
430
- secrets: stubProxy("secrets"),
431
- storage: stubProxy("storage"),
432
- vectors: stubProxy("vectors"),
433
- workflows: stubProxy("workflows")
434
- };
435
- actionContextRef ??= actionContext;
436
- const runRegistered = (expected, reference, context, args, allowInternal) => {
437
- const kind = registeredFunctionKind(reference);
438
- if (kind !== expected) {
439
- throw new LunoraError("INTERNAL", `expected a registered ${expected}, received a ${kind ?? "non-function"} reference`);
440
- }
441
- if (!allowInternal && registeredFunctionVisibility(reference) === "internal") {
442
- throw new LunoraError(
443
- "INTERNAL",
444
- `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.`
445
- );
446
- }
447
- return Promise.resolve(reference.handler(context, args ?? {}));
448
- };
449
- const runInternal = (expected, reference, context, args) => runRegistered(expected, reference, context, args, true);
450
- scheduledDispatchRef ??= (kind, reference, context, args) => {
451
- if (kind === "mutation") {
452
- return runInMutationTransaction(() => runRegistered("mutation", reference, context, args, true)).then((result) => {
453
- notifyMutationListeners();
454
- return result;
455
- });
456
- }
457
- return runInternal("action", reference, context, args);
458
- };
459
- const query = ((referenceOrInline, args) => {
460
- if (registeredFunctionKind(referenceOrInline)) {
461
- return runRegistered("query", referenceOrInline, queryContext, args, false);
462
- }
463
- return Promise.resolve(referenceOrInline(queryContext));
464
- });
465
- const mutation = ((referenceOrInline, args) => {
466
- if (registeredFunctionKind(referenceOrInline)) {
467
- return runInMutationTransaction(() => runRegistered("mutation", referenceOrInline, mutationContext, args, false)).then((result) => {
468
- notifyMutationListeners();
469
- return result;
470
- });
471
- }
472
- return runInMutationTransaction(() => referenceOrInline(mutationContext)).then((result) => {
473
- notifyMutationListeners();
474
- return result;
475
- });
476
- });
477
- const action = ((referenceOrInline, args) => {
478
- if (registeredFunctionKind(referenceOrInline)) {
479
- return runRegistered("action", referenceOrInline, actionContext, args, false);
480
- }
481
- return Promise.resolve(referenceOrInline(actionContext));
482
- });
483
- const subscribe = buildSubscribe(runRegistered, queryContext, mutationListeners);
484
- const harness = {
485
- action,
486
- close: closeDatabase,
487
- mutation,
488
- query,
489
- run: (function_) => runInMutationTransaction(() => function_(mutationContext)).then((result) => {
490
- notifyMutationListeners();
491
- return result;
492
- }),
493
- scheduler: schedulerControls,
494
- subscribe,
495
- // A scoped view shares the SAME sql/db handle (created once above), so
496
- // writes performed under an identity persist for every accessor.
497
- withIdentity: (next) => makeHarness(next)
498
- };
499
- return harness;
500
- };
501
- return makeHarness(null);
502
- };
503
-
504
- export { lunoraTest };