@lunora/testing 1.0.0-alpha.8 → 1.0.0-alpha.80
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/dist/index.d.mts +450 -183
- package/dist/index.d.ts +450 -183
- package/dist/index.mjs +1 -2
- package/dist/packem_shared/agentHarness-0b923YYC.mjs +1 -0
- package/dist/packem_shared/containsScorer-Dr5tjL-M.mjs +2 -0
- package/dist/packem_shared/evaluationAttributes-GZvtFa1N.mjs +1 -0
- package/dist/packem_shared/lunoraTest-DLoxStnZ.mjs +1 -0
- package/dist/playwright.d.mts +126 -0
- package/dist/playwright.d.ts +126 -0
- package/dist/playwright.mjs +1 -0
- package/package.json +18 -4
- package/dist/packem_shared/lunoraTest-Dm9lhBEx.mjs +0 -422
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
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-DLoxStnZ.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{LunoraError as j}from"@lunora/errors";import{runShardMigrations as H,createShardCtxDb as J}from"@lunora/shard-engine";import{evaluationAttributes as U}from"./evaluationAttributes-GZvtFa1N.mjs";import{DatabaseSync as W}from"node:sqlite";const Z=(o,i,n,l,x)=>{let m=x,v=1;const u=new Map,h=[],g=(t,a,d={})=>{const e=`fake-job-${String(v)}`;return v+=1,u.set(e,{args:d,enqueuedAt:m,functionPath:a,id:e,scheduledFor:t}),e},w=t=>typeof t=="string"?t:t.name??t.binding??"",A={cancel:t=>{const a=u.has(t);return u.delete(t),Promise.resolve({cancelled:a})},get:t=>Promise.resolve(u.get(t)??null),list:()=>Promise.resolve([...u.values()]),runAfter:(t,a,d)=>{const e=g(m+t,w(a),d);return Promise.resolve(e)},runAt:(t,a,d)=>{const e=g(t,w(a),d);return Promise.resolve(e)}},T=async t=>{u.delete(t.id);const a=l().get(t.functionPath);if(a===void 0){console.warn(`[fake-scheduler] unknown functionPath "${t.functionPath}" — job ${t.id} dropped`);return}if(a.kind==="mutation"||a.kind==="action"){const d=o(),e=a.kind==="action"?n():i();await d(a.kind,a,e,t.args)}else console.warn(`[fake-scheduler] functionPath "${t.functionPath}" is a ${a.kind} — only mutations and actions can be scheduled; job ${t.id} dropped`)},E=async t=>{const a=[...u.values()].filter(s=>s.scheduledFor<=t).toSorted((s,f)=>s.scheduledFor-f.scheduledFor),d=[];let e=0;for(const s of a)if(u.has(s.id)){e+=1;try{await T(s)}catch(f){const N={args:s.args,error:f,functionPath:s.functionPath,id:s.id};d.push(N),h.push(N)}}return{executed:e,failed:d}},I=async(t,a)=>{const{executed:d,failed:e}=await E(t);if(e.length>0&&(a?.throwOnError??!0)){const[s]=e;throw e.length===1&&s!==void 0?s.error:new AggregateError(e.map(f=>f.error),`${String(e.length)} scheduled jobs failed: ${e.map(f=>f.functionPath).join(", ")}`)}return d};return{controls:{advance:(t,a)=>(m+=t,I(m,a)),failures:()=>[...h],list:()=>[...u.values()],runPending:t=>I(Number.POSITIVE_INFINITY,t)},scheduler:A}},z=()=>{const o=new W(":memory:"),i=n=>({one(){if(n.length!==1)throw new j("INTERNAL",`expected exactly one row, received ${String(n.length)}`);const[l]=n;return l},[Symbol.iterator](){return n[Symbol.iterator]()},toArray(){return n}});return{close:()=>{o.close()},sql:{exec:(n,...l)=>{const x=o.prepare(n).all(...l);return i(x)}}}},C=o=>{if(typeof o!="object"||o===null)return;const{kind:i}=o;if(i==="query"||i==="mutation"||i==="action")return i},X=o=>typeof o=="object"&&o!==null&&o.visibility==="internal"?"internal":"public",Q=o=>{throw new j("INTERNAL",`ctx.${o} is not available in the in-memory @lunora/testing harness (v1)`)},p=o=>new Proxy((...i)=>Q(o),{apply:()=>Q(o),get:()=>Q(o)}),K={spanId:"0000000000000001",traceId:"00000000000000000000000000000001"},ee={addEvent:()=>{},addLink:()=>{},recordEvaluation:()=>{},recordException:()=>{},setAttribute:()=>{},setAttributes:()=>{},spanContext:()=>K},te=()=>{const o={attributes:{},events:[],links:[]},i={addEvent:(n,l)=>{o.events.push({...l===void 0?{}:{attributes:{...l}},name:n})},addLink:n=>{o.links.push({spanId:n.spanId,traceId:n.traceId})},recordEvaluation:n=>{Object.assign(o.attributes,U(n))},recordException:n=>{const l={"exception.message":n instanceof Error?n.message:String(n),"exception.type":n instanceof Error?n.constructor.name:"Error"};n instanceof Error&&n.stack!==void 0&&(l["exception.stacktrace"]=n.stack),i.addEvent("exception",l)},setAttribute:(n,l)=>{o.attributes[n]=l},setAttributes:n=>{Object.assign(o.attributes,n)},spanContext:()=>K};return{handle:i,recorded:o}},M=async(o,i)=>await i(M,ee),B={count:()=>{},gauge:()=>{},record:()=>{}},F={debug:()=>{},error:()=>{},event:()=>{},fatal:()=>{},info:()=>{},log:()=>{},trace:()=>{},warn:()=>{},with:()=>F},re=(o,i,n)=>(l,x)=>{let m=!1;const v=[];let u,h,g=0,w=0;const A=()=>C(l)?o("query",l,i,x,!1):Promise.resolve(l(i)),T=(e,s)=>{if(e<w)return;w=e;const f={done:!1,value:s};if(v.length===0)u=f,h=void 0;else{u=void 0,h=void 0;for(const N of v.splice(0))N.resolve(f)}},E=(e,s)=>{if(!(e<w))if(w=e,v.length===0)h={error:s},u=void 0;else{u=void 0,h=void 0;for(const f of v.splice(0))f.reject(s)}},I=e=>s=>{T(e,s)},t=e=>s=>{E(e,s)},a=()=>{if(m)return;g+=1;const e=g;A().then(I(e)).catch(t(e))};n.add(a);const d={[Symbol.asyncIterator](){return d},next:()=>{if(m)return Promise.resolve({done:!0,value:void 0});if(w===g){if(h!==void 0){const{error:e}=h;return h=void 0,Promise.reject(e)}if(u!==void 0){const e=u;return u=void 0,Promise.resolve(e)}}return w<g?new Promise((e,s)=>{v.push({reject:s,resolve:e})}):A().then(e=>{if(h!==void 0){const{error:s}=h;throw h=void 0,s}if(u!==void 0){const s=u;return u=void 0,s}return{done:!1,value:e}})},return:()=>{m=!0,n.delete(a);for(const e of v.splice(0))e.resolve({done:!0,value:void 0});return Promise.resolve({done:!0,value:void 0})}};return A().then(I(0)).catch(t(0)),d},ce=(o,i)=>{const{close:n,sql:l}=z(),x=o;H(l,x);const m=J({schema:x,sql:l}),v=b=>{l.exec.call(l,b)};let u=Promise.resolve();const h=b=>{const $=async()=>{v("BEGIN");try{const y=await b();return v("COMMIT"),y}catch(y){try{v("ROLLBACK")}catch{}throw y}},k=u.then($);return u=k.then(()=>{},()=>{}),k};let g=!1;const w=()=>{g||(g=!0,n())},A=new Map(Object.entries(i?.functions??{}).map(([b,$])=>[b,$])),T=new Set,E=()=>{for(const b of T)b()};let I,t,a;const d=i?.now??Date.now(),e=te(),{controls:s,scheduler:f}=Z(()=>{if(I===void 0)throw new j("INTERNAL","[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed");return I},()=>{if(t===void 0)throw new j("INTERNAL","[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed");return t},()=>{if(a===void 0)throw new j("INTERNAL","[fake-scheduler] actionContext not yet available — scheduler.advance called before harness construction completed");return a},()=>A,d),N=b=>{const $={getIdentity:()=>Promise.resolve(b??null),userId:b?.userId??null},k={auth:$,db:m,env:i?.env,log:F,metrics:B,now:d,span:e.handle,trace:M,runQuery:(r,c)=>S("query",r,k,c),secrets:p("secrets"),storage:p("storage"),vectors:p("vectors")},y={auth:$,db:m,env:i?.env,log:F,metrics:B,now:d,span:e.handle,trace:M,runMutation:(r,c)=>S("mutation",r,y,c),runQuery:(r,c)=>S("query",r,k,c),scheduler:f,secrets:p("secrets"),storage:p("storage"),vectors:p("vectors"),workflows:p("workflows")};t??=y;const R={auth:$,db:m,env:i?.env,fetch:i?.fetch??p("fetch"),log:F,metrics:B,now:d,span:e.handle,trace:M,runAction:(r,c)=>S("action",r,R,c),runMutation:(r,c)=>S("mutation",r,y,c),runQuery:(r,c)=>S("query",r,k,c),scheduler:f,secrets:p("secrets"),storage:p("storage"),vectors:p("vectors"),workflows:p("workflows")};a??=R;const q=(r,c,P,L,O)=>{const D=C(c);if(D!==r)throw new j("INTERNAL",`expected a registered ${r}, received a ${D??"non-function"} reference`);if(!O&&X(c)==="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(c.handler(P,L??{}))},S=(r,c,P,L)=>q(r,c,P,L,!0);I??=(r,c,P,L)=>r==="mutation"?h(()=>q("mutation",c,P,L,!0)).then(O=>(E(),O)):S("action",c,P,L);const V=((r,c)=>C(r)?q("query",r,k,c,!1):Promise.resolve(r(k))),Y=((r,c)=>C(r)?h(()=>q("mutation",r,y,c,!1)).then(P=>(E(),P)):h(()=>r(y)).then(P=>(E(),P))),_=((r,c)=>C(r)?q("action",r,R,c,!1):Promise.resolve(r(R))),G=re(q,k,T);return{action:_,close:w,mutation:Y,query:V,run:r=>h(()=>r(y)).then(c=>(E(),c)),scheduler:s,subscribe:G,wideEvent:()=>e.recorded,withIdentity:r=>N(r)}};return N(null)};export{ce 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.
|
|
3
|
+
"version": "1.0.0-alpha.80",
|
|
4
4
|
"description": "Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -40,15 +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/
|
|
50
|
-
"@lunora/
|
|
51
|
-
"@lunora/
|
|
53
|
+
"@lunora/agent": "1.0.0-alpha.32",
|
|
54
|
+
"@lunora/errors": "1.0.0-alpha.9",
|
|
55
|
+
"@lunora/mail": "1.0.0-alpha.36",
|
|
56
|
+
"@lunora/server": "1.0.0-alpha.53",
|
|
57
|
+
"@lunora/shard-engine": "1.0.0-alpha.1"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@playwright/test": "^1.61.1"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@playwright/test": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
52
66
|
},
|
|
53
67
|
"engines": {
|
|
54
68
|
"node": "^22.15.0 || >=24.11.0"
|