@stacksjs/testing 0.75.226 → 0.75.233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Poll `read` until it returns something truthy, and return that. Throws
3
+ * after `timeoutMs` (default 12 s) with `label` in the message.
4
+ */
5
+ export declare function waitFor<T>(read: () => Promise<T> | T, timeoutMs?: number, options?: { intervalMs?: number, label?: string }): Promise<NonNullable<T>>;
6
+ /**
7
+ * Whether `url` matches a `Fetch.enable` URL pattern the way Chrome matches
8
+ * it: the whole URL, `*` any run of characters (none included), `?` exactly
9
+ * one, a backslash makes the next character literal.
10
+ */
11
+ export declare function matchesUrlPattern(pattern: string, url: string): boolean;
12
+ /** Whether a `Fetch.requestPaused` event is one `pattern` would have paused. */
13
+ export declare function pausedRequestMatches(pattern: FetchPattern, paused: Pick<PausedRequest, 'request' | 'resourceType' | 'responseStatusCode' | 'responseErrorReason'>): boolean;
14
+ /** Open a session on a DevTools WebSocket URL (`ws://127.0.0.1:9222/devtools/page/...`). */
15
+ export declare function connect(url: string, options?: CdpOptions): Promise<Cdp>;
16
+ /** The Chrome to drive: `CHROME_PATH`, else the first installed one found. Null when there is none. */
17
+ export declare function findChrome(): string | null;
18
+ /** True when this Chrome would be killed by GoogleUpdater (it still carries the updater's keys). */
19
+ export declare function chromeHasUpdater(executable: string): boolean;
20
+ /**
21
+ * A private clone of a macOS Chrome that GoogleUpdater leaves alone (see the
22
+ * module docs): `cp -cR`, the `KS*` keys deleted from `Info.plist`, its own
23
+ * `CFBundleIdentifier`, `codesign --force --deep --sign -`. Kept in
24
+ * `directory` (default a per-version folder in the temp directory) and
25
+ * reused while its version matches the source's.
26
+ */
27
+ export declare function prepareMacChrome(options?: { source?: string, directory?: string }): PreparedChrome;
28
+ /**
29
+ * Answer live-reload streams matching `patterns` (default stx's) with one that
30
+ * asks to retry in a day, so a file saved mid-run cannot reload the page.
31
+ * Only those requests: every other paused request is left to the session's
32
+ * other Fetch handlers (`Cdp.intercept`, or your own `Fetch.enable`).
33
+ * `launch()` calls this unless `quietLiveReload` is false. Resolves with a
34
+ * function that stops it.
35
+ */
36
+ export declare function quietLiveReload(cdp: Cdp, patterns?: readonly string[]): Promise<() => Promise<void>>;
37
+ /**
38
+ * Start a private headless Chrome and open a session on its page, with the
39
+ * `Page`, `Runtime`, `Network` and `Log` domains enabled.
40
+ */
41
+ export declare function launch(options?: LaunchOptions): Promise<LaunchedChrome>;
42
+ /**
43
+ * Wait until the document has loaded and no request has been in flight for
44
+ * `quietMs` (event streams excluded, see `Cdp`). Resolves true once settled,
45
+ * false at the timeout (a page that never goes quiet is often the finding);
46
+ * rejects only if Chrome goes away.
47
+ */
48
+ export declare function settle(cdp: Cdp, options?: SettleOptions): Promise<boolean>;
49
+ /** Navigate and wait for the page to settle. Resolves with whether it settled. */
50
+ export declare function navigate(cdp: Cdp, url: string, options?: SettleOptions): Promise<boolean>;
51
+ /** Count how often each entry appears, keeping those seen more than once. */
52
+ export declare function duplicateRequests(requests: readonly string[]): Record<string, number>;
53
+ /**
54
+ * Split a visit's requests into page loads (see `PageAuditOptions.pageLoads`).
55
+ * The first new document is the page itself; each later one, and each
56
+ * same-document navigation to another path, starts the next load.
57
+ */
58
+ export declare function splitPageLoads(url: string, events: readonly PageAuditEvent[], mode?: 'navigation' | 'visit'): PageLoad[];
59
+ /** The highest count each request reached in any one load. */
60
+ export declare function loadDuplicates(loads: readonly PageLoad[]): Record<string, number>;
61
+ /**
62
+ * Load `url` once and report what it cost and what went wrong: the same API
63
+ * request made more than once in one page load, failed same-origin requests,
64
+ * and console errors, and where the page landed. A page that sends you on
65
+ * (`/today` to `/home`) is counted as two loads (`pageLoads`). Never throws
66
+ * for a page problem: a launch or navigation failure is reported in `errors`.
67
+ *
68
+ * @example
69
+ * const report = await auditPage('http://localhost:3000/dashboard', {
70
+ * beforeNavigate: cdp => cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('token', ${JSON.stringify(token)})` }),
71
+ * })
72
+ * expect(report.duplicates).toEqual({})
73
+ * expect(report.failed).toEqual([])
74
+ */
75
+ export declare function auditPage(url: string, options?: PageAuditOptions): Promise<PageAuditReport>;
76
+ /** One line per page: `/today -> /home: 12 api | dup 2x GET /api/me | failed 404 /api/x | errors ...`. */
77
+ export declare function formatPageAudit(report: PageAuditReport): string;
78
+ /** True when the report found nothing: no duplicates, failures or errors, and the page settled. */
79
+ export declare function pageAuditClean(report: PageAuditReport): boolean;
80
+ /** stx's live-reload stream. */
81
+ export declare const LIVE_RELOAD_PATTERNS: readonly string[];
82
+ export declare interface CdpOptions {
83
+ commandTimeoutMs?: number
84
+ }
85
+ /** One `Fetch.enable` pattern, as the protocol spells it. */
86
+ export declare interface FetchPattern {
87
+ urlPattern?: string
88
+ resourceType?: string
89
+ requestStage?: 'Request' | 'Response'
90
+ }
91
+ /** The parts of a `Fetch.requestPaused` event a handler usually reads. */
92
+ export declare interface PausedRequest {
93
+ requestId: string
94
+ request: { url: string, method: string, headers: Record<string, string>, postData?: string }
95
+ resourceType?: string
96
+ responseStatusCode?: number
97
+ responseErrorReason?: string
98
+ networkId?: string
99
+ [key: string]: any
100
+ }
101
+ export declare interface PreparedChrome {
102
+ executable: string
103
+ app: string
104
+ reused: boolean
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Launching
108
+ // ---------------------------------------------------------------------------
109
+ export declare interface Viewport {
110
+ width: number
111
+ height: number
112
+ deviceScaleFactor?: number
113
+ mobile?: boolean
114
+ }
115
+ export declare interface LaunchOptions extends CdpOptions {
116
+ chromePath?: string
117
+ stableMacChrome?: boolean
118
+ profile?: string
119
+ headless?: boolean
120
+ viewport?: Viewport | false
121
+ args?: string[]
122
+ quietLiveReload?: string[] | false
123
+ acceptDialogs?: boolean
124
+ userAgent?: boolean | string
125
+ startTimeoutMs?: number
126
+ }
127
+ export declare interface LaunchedChrome {
128
+ cdp: Cdp
129
+ close: () => Promise<void>
130
+ target: string
131
+ port: number
132
+ chromePath: string
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Settling and navigating
136
+ // ---------------------------------------------------------------------------
137
+ export declare interface SettleOptions {
138
+ quietMs?: number
139
+ timeoutMs?: number
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Page audit
143
+ // ---------------------------------------------------------------------------
144
+ export declare interface PageAuditOptions extends SettleOptions {
145
+ cdp?: Cdp
146
+ launch?: LaunchOptions
147
+ viewport?: Viewport
148
+ isApiRequest?: (url: URL, method: string) => boolean
149
+ beforeNavigate?: (cdp: Cdp) => Promise<void> | void
150
+ pageLoads?: 'navigation' | 'visit'
151
+ }
152
+ /** The requests one page load made. */
153
+ export declare interface PageLoad {
154
+ url: string
155
+ apiRequests: string[]
156
+ duplicates: Record<string, number>
157
+ }
158
+ export declare interface PageAuditReport {
159
+ url: string
160
+ finalUrl?: string
161
+ apiRequests: string[]
162
+ duplicates: Record<string, number>
163
+ loads?: PageLoad[]
164
+ failed: string[]
165
+ errors: string[]
166
+ settled: boolean
167
+ durationMs: number
168
+ }
169
+ // ---------------------------------------------------------------------------
170
+ // The protocol session
171
+ // ---------------------------------------------------------------------------
172
+ declare type Listener = (params: any) => void;
173
+ /** Answers a paused request with `cdp.send('Fetch.continueRequest' | 'Fetch.fulfillRequest' | ...)`. */
174
+ export type FetchHandler = (request: PausedRequest, cdp: Cdp) => void | Promise<void>;
175
+ /** What happened during a visit, in order, for `splitPageLoads`. */
176
+ export type PageAuditEvent = | { type: 'request', request: string }
177
+ /** `sameDocument`: a history API or fragment navigation (`Page.navigatedWithinDocument`), not a new document. */
178
+ | { type: 'navigated', url: string, sameDocument: boolean }
179
+ /**
180
+ * One DevTools Protocol session.
181
+ *
182
+ * Besides commands and events it keeps two things a test usually wants:
183
+ *
184
+ * - `errors`: uncaught exceptions, `console.error` calls, error log entries
185
+ * (favicon misses excluded) and failed requests (cancellations excluded);
186
+ * - `inFlight`: the requests still loading, so a stalled page can say what it
187
+ * is waiting on. An event stream (`text/event-stream`: messages,
188
+ * notifications, live reload) stays open for the life of the page, so it
189
+ * leaves `inFlight` as soon as its response starts; counting it would keep
190
+ * every wait for a quiet network running to its timeout.
191
+ *
192
+ * Enable the `Network`, `Runtime` and `Log` domains for those to fill;
193
+ * `launch()` does.
194
+ *
195
+ * ## Several Fetch handlers on one session
196
+ *
197
+ * Chrome keeps one set of `Fetch.enable` patterns per session: a second
198
+ * `Fetch.enable` replaces the first, `Fetch.disable` drops them all, and every
199
+ * paused request goes to every `Fetch.requestPaused` listener. So the session
200
+ * layers them:
201
+ *
202
+ * - `intercept(patterns, handler)` registers a handler that is given only the
203
+ * paused requests its own patterns match (`launch()` quiets live reload
204
+ * this way);
205
+ * - `send('Fetch.enable', { patterns })` and `send('Fetch.disable')` set and
206
+ * clear one more layer, for code that drives Fetch itself, and the session
207
+ * enables the union of every layer. `on('Fetch.requestPaused')` listeners
208
+ * are given the requests that layer's patterns match, and not those only an
209
+ * `intercept()` handler asked for;
210
+ * - the first answer to a paused request wins: a second
211
+ * `Fetch.continueRequest` / `fulfillRequest` / `failRequest` /
212
+ * `continueResponse` for it resolves without reaching Chrome (which would
213
+ * reject it), until the same request pauses again at its response stage;
214
+ * - a paused request nothing would answer is continued.
215
+ */
216
+ export declare class Cdp {
217
+ errors: string[];
218
+ inFlight: Map<string, string>;
219
+ closed: boolean;
220
+ constructor(socket: WebSocket, options?: CdpOptions);
221
+ intercept(patterns: FetchPattern[], handler: FetchHandler): Promise<() => Promise<void>>;
222
+ on(method: string, listener: Listener): () => void;
223
+ off(method: string, listener: Listener): void;
224
+ once<T = any>(method: string, match?: (params: T) => boolean, timeoutMs?: number): Promise<T>;
225
+ send<T = any>(method: string, params?: Record<string, unknown>): Promise<T>;
226
+ evaluate<T = any>(expression: string): Promise<T>;
227
+ disconnect(): void;
228
+ }
@@ -0,0 +1,6 @@
1
+ // @bun
2
+ import{existsSync as C,mkdirSync as U,mkdtempSync as M,readFileSync as q,renameSync as B,rmSync as R}from"fs";import{tmpdir as L}from"os";import{join as m}from"path";import b from"process";async function S(e,t=12000,r={}){let n=Date.now(),s=await e();while(!s&&Date.now()-n<t)await Bun.sleep(r.intervalMs??100),s=await e();if(!s)throw Error(`Timed out after ${t}ms${r.label?` waiting for ${r.label}`:""}`);return s}var _=new Set(["Fetch.continueRequest","Fetch.fulfillRequest","Fetch.failRequest","Fetch.continueResponse"]);function H(e,t){let r="";for(let n=0;n<e.length;n++){let s=e[n];if(s==="\\"&&n+1<e.length)r+=T(e[++n]);else if(s==="*")r+="[\\s\\S]*";else if(s==="?")r+="[\\s\\S]";else r+=T(s)}return new RegExp(`^${r}$`).test(t)}function T(e){return e.replace(/[.*+?^${}()|[\]\\/]/g,"\\$&")}function F(e,t){let r=t.responseStatusCode!=null||t.responseErrorReason!=null?"Response":"Request";if((e.requestStage??"Request")!==r)return!1;if(e.resourceType&&e.resourceType!==t.resourceType)return!1;return H(e.urlPattern??"*",t.request?.url??"")}class O{socket;id=0;pending=new Map;listeners=new Map;commandTimeoutMs;interceptors=[];ownFetch=null;answered=new Map;errors=[];inFlight=new Map;closed=!1;constructor(e,t={}){this.socket=e;this.commandTimeoutMs=t.commandTimeoutMs??30000,e.addEventListener("close",()=>{this.closed=!0;for(let[r,n]of this.pending)clearTimeout(n.timer),n.reject(Error("Chrome closed the connection")),this.pending.delete(r)}),e.addEventListener("message",(r)=>this.receive(String(r.data)))}receive(e){let t;try{t=JSON.parse(e)}catch{return}if(t.id){let r=this.pending.get(t.id);if(!r)return;if(this.pending.delete(t.id),clearTimeout(r.timer),t.error)r.reject(Error(t.error.message));else r.resolve(t.result);return}if(this.track(t.method,t.params??{}),t.method==="Fetch.requestPaused"&&!this.routePaused(t.params??{}))return;this.dispatch(t.method,t.params??{})}dispatch(e,t){for(let r of[...this.listeners.get(e)??[]])try{r(t)}catch(n){this.errors.push(`Listener for ${e} threw: ${n?.message??String(n)}`)}}routePaused(e){this.remember(e.requestId,!1);let t=this.interceptors.filter((s)=>s.patterns.some((i)=>F(i,e)));for(let s of t)try{let i=s.handler(e,this);if(i&&typeof i.catch==="function")i.catch((a)=>this.errors.push(`Fetch handler threw: ${a?.message??String(a)}`))}catch(i){this.errors.push(`Fetch handler threw: ${i?.message??String(i)}`)}let r=(this.listeners.get("Fetch.requestPaused")?.size??0)>0,n=this.ownFetch?this.ownFetch.patterns.some((s)=>F(s,e)):!1;if(!t.length&&!n&&!r)this.send("Fetch.continueRequest",{requestId:e.requestId}).catch(()=>{});return r&&(n||!t.length)}remember(e,t){if(this.answered.delete(e),this.answered.set(e,t),this.answered.size>2000){for(let[r]of this.answered)if(this.answered.delete(r),this.answered.size<=1000)break}}async applyFetch(){let e=[...this.interceptors.flatMap((t)=>t.patterns),...this.ownFetch?.patterns??[]];if(!e.length)return await this.command("Fetch.disable");return await this.command("Fetch.enable",{...this.ownFetch?.params,patterns:e})}async intercept(e,t){if(!e.length)throw TypeError("intercept needs at least one pattern");let r={patterns:e.map((s)=>({...s})),handler:t};this.interceptors.push(r);try{await this.applyFetch()}catch(s){throw this.interceptors=this.interceptors.filter((i)=>i!==r),s}let n=!1;return async()=>{if(n)return;if(n=!0,this.interceptors=this.interceptors.filter((s)=>s!==r),!this.closed)await this.applyFetch()}}track(e,t){if(e==="Network.requestWillBeSent")this.inFlight.set(t.requestId,`${t.request?.method} ${t.request?.url}`);else if(e==="Network.loadingFinished"||e==="Network.loadingFailed")this.inFlight.delete(t.requestId);else if(e==="Network.responseReceived"&&t.response?.mimeType==="text/event-stream")this.inFlight.delete(t.requestId);if(e==="Runtime.exceptionThrown")this.errors.push(t.exceptionDetails?.exception?.description||t.exceptionDetails?.text||"Runtime exception");else if(e==="Runtime.consoleAPICalled"&&t.type==="error")this.errors.push((t.args||[]).map((r)=>r.value??r.description??"").join(" "));else if(e==="Log.entryAdded"&&t.entry?.level==="error"&&!String(t.entry.text).includes("favicon"))this.errors.push(t.entry.text);else if(e==="Network.loadingFailed"&&!t.canceled)this.errors.push(`Network: ${t.errorText||"request failed"}`)}on(e,t){let r=this.listeners.get(e);if(!r)this.listeners.set(e,r=new Set);return r.add(t),()=>this.off(e,t)}off(e,t){this.listeners.get(e)?.delete(t)}once(e,t=()=>!0,r=this.commandTimeoutMs){return new Promise((n,s)=>{if(this.closed)return s(Error("Chrome closed the connection"));let i=setTimeout(()=>{c(),clearInterval(a),s(Error(`Timed out after ${r}ms waiting for ${e}`))},r),a=setInterval(()=>{if(!this.closed)return;clearTimeout(i),clearInterval(a),c(),s(Error("Chrome closed the connection"))},100),c=this.on(e,(p)=>{if(!t(p))return;clearTimeout(i),clearInterval(a),c(),n(p)})})}send(e,t={}){if(e==="Fetch.enable"){let{patterns:r,...n}=t;return this.ownFetch={patterns:r?.length?r.map((s)=>({...s})):[{urlPattern:"*"}],params:n},this.closed?this.command(e,t):this.applyFetch()}if(e==="Fetch.disable")return this.ownFetch=null,this.closed?this.command(e,t):this.applyFetch();if(_.has(e)&&typeof t.requestId==="string"){if(this.answered.get(t.requestId)===!0)return Promise.resolve({});if(this.answered.has(t.requestId))this.remember(t.requestId,!0)}return this.command(e,t)}command(e,t={}){let r=++this.id;return new Promise((n,s)=>{if(this.closed)return s(Error("Chrome closed the connection"));let i=setTimeout(()=>{this.pending.delete(r),s(Error(`Chrome command timed out: ${e}`))},this.commandTimeoutMs);this.pending.set(r,{resolve:n,reject:s,timer:i}),this.socket.send(JSON.stringify({id:r,method:e,params:t}))})}async evaluate(e){let t=await this.send("Runtime.evaluate",{expression:e,awaitPromise:!0,returnByValue:!0});if(t.exceptionDetails)throw Error(t.exceptionDetails.exception?.description||t.exceptionDetails.text);return t.result?.value}disconnect(){try{this.socket.close()}catch{}}}async function j(e,t={}){let r=new WebSocket(e);return await new Promise((n,s)=>{r.addEventListener("open",()=>n(),{once:!0}),r.addEventListener("error",()=>s(Error(`Could not connect to Chrome at ${e}`)),{once:!0})}),new O(r,t)}var D=["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome","/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing","/Applications/Chromium.app/Contents/MacOS/Chromium","/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"],V=["google-chrome","google-chrome-stable","chromium","chromium-browser","chrome"],I=["KSProductID","KSUpdateURL","KSChannelID","KSVersion"],E="dev.stacks.testing.chrome";function W(){if(b.env.CHROME_PATH)return b.env.CHROME_PATH;if(b.platform==="darwin"){let e=D.find((t)=>C(t));if(e)return e}for(let e of V){let t=Bun.which(e);if(t)return t}return null}function N(e){let t=/^(.*?\.app)\/Contents\/MacOS\/[^/]+$/.exec(e);return t?t[1]:null}function P(e,t){let r=Bun.spawnSync(["/usr/libexec/PlistBuddy","-c",t,e],{stdout:"pipe",stderr:"pipe"});return{ok:r.exitCode===0,out:r.stdout.toString().trim()}}function G(e){let t=N(e);if(!t||b.platform!=="darwin")return!1;let r=m(t,"Contents/Info.plist");return C(r)&&I.some((n)=>P(r,`Print :${n}`).ok)}function K(e={}){if(b.platform!=="darwin")throw Error("prepareMacChrome is for macOS; elsewhere launch Chrome as installed");let t=e.source??D[0],r=N(t);if(!r||!C(r))throw Error(`No Chrome app bundle at ${t}`);let n=m(r,"Contents/Info.plist"),s=P(n,"Print :CFBundleShortVersionString").out||"unknown",i=P(n,"Print :CFBundleExecutable").out||"Google Chrome",a=e.directory??m(L(),`stacks-chrome-${s.replace(/[^\w.-]/g,"_")}`),c=m(a,"Chrome.app"),p=m(c,"Contents/MacOS",i),h=m(c,"Contents/Info.plist");if(C(p)&&P(h,"Print :CFBundleIdentifier").out===E&&P(h,"Print :CFBundleShortVersionString").out===s)return{executable:p,app:c,reused:!0};U(a,{recursive:!0});let g=M(m(a,"staging-")),l=m(g,"Chrome.app"),o=(f,y)=>{let d=Bun.spawnSync(f,{stdout:"pipe",stderr:"pipe"});if(d.exitCode!==0)throw Error(`${y} failed: ${d.stderr.toString().trim()}`)};try{o(["cp","-cR",r,l],"Cloning Chrome");let f=m(l,"Contents/Info.plist");for(let y of I)P(f,`Delete :${y}`);o(["/usr/libexec/PlistBuddy","-c",`Set :CFBundleIdentifier ${E}`,f],"Renaming the Chrome bundle"),o(["codesign","--force","--deep","--sign","-",l],"Re-signing Chrome"),R(c,{recursive:!0,force:!0}),B(l,c)}finally{R(g,{recursive:!0,force:!0})}return{executable:p,app:c,reused:!1}}var z=["*/_stx/hmr*"];async function J(e,t=z){if(!t.length)return async()=>{};return await e.intercept(t.map((r)=>({urlPattern:r})),(r,n)=>{n.send("Fetch.fulfillRequest",{requestId:r.requestId,responseCode:200,responseHeaders:[{name:"Content-Type",value:"text/event-stream"}],body:Buffer.from(`retry: 86400000
3
+
4
+ `).toString("base64")}).catch(()=>{})})}function Y(e){let t=e.chromePath??b.env.CHROME_PATH;if(t)return t;let r=W();if(!r)throw Error("No Chrome found: install Google Chrome or Chromium, or set CHROME_PATH");if((e.stableMacChrome??!0)&&b.platform==="darwin"&&G(r))return K({source:r}).executable;return r}async function Q(e={}){let t=Y(e);if(!C(t))throw Error(`Chrome was not found at ${t}`);let r=e.profile||M(m(L(),"stacks-browser-"));R(m(r,"DevToolsActivePort"),{force:!0});let n=Bun.spawn([t,...e.headless===!1?[]:["--headless=new"],"--remote-debugging-port=0",`--user-data-dir=${r}`,"--no-first-run","--no-default-browser-check","--disable-background-networking","--disable-component-update","--disable-extensions","--hide-scrollbars","--use-mock-keychain",...e.args??[],"about:blank"],{stdout:"ignore",stderr:"ignore"}),s,i=!1,a=async()=>{if(i)return;if(i=!0,s?.disconnect(),n.kill(),await n.exited,!e.profile)R(r,{recursive:!0,force:!0})};try{let c=m(r,"DevToolsActivePort");await S(()=>C(c)&&q(c,"utf8").includes(`
5
+ `),e.startTimeoutMs??30000,{label:"Chrome to start"});let p=Number(q(c,"utf8").split(`
6
+ `)[0]),h=await S(async()=>{let o=await fetch(`http://127.0.0.1:${p}/json/list`).then((f)=>f.json()).catch(()=>[]);return o.length?o:null},12000,{label:"a page target"}),g=h.find((o)=>o.type==="page"&&o.url==="about:blank")||h.find((o)=>o.type==="page");if(!g)throw Error("Chrome did not expose a page target");s=await j(g.webSocketDebuggerUrl,e);let l=s;if(await Promise.all([l.send("Page.enable"),l.send("Runtime.enable"),l.send("Network.enable"),l.send("Log.enable"),...e.viewport===!1?[]:[l.send("Emulation.setDeviceMetricsOverride",{deviceScaleFactor:1,mobile:!1,width:1440,height:1000,...e.viewport})]]),e.userAgent!==!1){let o=typeof e.userAgent==="string"?e.userAgent:String((await l.send("Browser.getVersion")).userAgent||"").replace("HeadlessChrome","Chrome");if(o)await l.send("Network.setUserAgentOverride",{userAgent:o})}if(e.quietLiveReload!==!1)await J(l,e.quietLiveReload);if(e.acceptDialogs!==!1)l.on("Page.javascriptDialogOpening",(o)=>{l.errors.push(`Dialog (${o.type}) on ${o.url}: ${o.message||"(no message)"}`),l.send("Page.handleJavaScriptDialog",{accept:!0}).catch(()=>{})});return{cdp:l,close:a,target:g.webSocketDebuggerUrl,port:p,chromePath:t}}catch(c){throw await a(),c}}async function X(e,t={}){let r=t.quietMs??1000,n=t.timeoutMs??15000,s=Date.now(),i=0;while(Date.now()-s<n){if(e.closed)throw Error("Chrome closed the connection");if(e.inFlight.size>0||await e.evaluate("document.readyState !== 'complete'").catch(()=>!0))i=0;else if(!i)i=Date.now();else if(Date.now()-i>=r)return!0;await Bun.sleep(100)}return!1}async function Z(e,t,r={}){let n=await e.send("Page.navigate",{url:t});if(n?.errorText)throw Error(`Could not open ${t}: ${n.errorText}`);return await X(e,r)}var ee=(e)=>e.pathname.startsWith("/api/")&&!e.pathname.endsWith("/events");function te(e){let t={};for(let r of e)t[r]=(t[r]||0)+1;return Object.fromEntries(Object.entries(t).filter(([,r])=>r>1))}function k(e){try{let t=new URL(e);return`${t.origin}${t.pathname}`}catch{return e.replace(/[?#].*$/,"")}}function re(e,t,r="navigation"){let n=[{url:e,apiRequests:[]}],s=!1;for(let i of t){let a=n.at(-1);if(i.type==="request"){a.apiRequests.push(i.request);continue}let c=r==="navigation"&&s&&(i.sameDocument?k(i.url)!==k(a.url):!0);if(s=!0,c)n.push({url:i.url,apiRequests:[]});else a.url=i.url}return n.map((i)=>({...i,duplicates:te(i.apiRequests)}))}function ne(e){let t={};for(let r of e)for(let[n,s]of Object.entries(r.duplicates))t[n]=Math.max(t[n]??0,s);return t}async function ce(e,t={}){let r=Date.now(),n=new URL(e).origin,s=t.isApiRequest??ee,i=[],a=[],c=[],p=null,h=t.cdp,g=[],l=h?.errors.length??0,o,f=e,y=async(d,x=[])=>{let u=re(e,a,t.pageLoads),w=f;if(h&&!h.closed){let v=await h.evaluate("location.href").catch(()=>null);if(v&&v!=="about:blank"&&!v.startsWith("chrome-error:"))w=v}return{url:e,finalUrl:w,apiRequests:i,duplicates:ne(u),loads:u,failed:c,errors:[...h?.errors.slice(l)??[],...x],settled:d,durationMs:Date.now()-r}};try{if(!h)p=await Q(t.launch),h=p.cdp;let d=h;if(g.push(d.on("Network.requestWillBeSent",(u)=>{let w=new URL(u.request.url);if(w.origin===n&&s(w,u.request.method)){let v=`${u.request.method} ${w.pathname}${w.search}`;i.push(v),a.push({type:"request",request:v})}})),g.push(d.on("Network.responseReceived",(u)=>{let w=new URL(u.response.url);if(w.origin===n&&u.response.status>=400)c.push(`${u.response.status} ${w.pathname}`)})),g.push(d.on("Page.frameNavigated",(u)=>{if(u.frame?.parentId)return;o=u.frame?.id??o,f=u.frame?.unreachableUrl||`${u.frame?.url??f}${u.frame?.urlFragment??""}`,a.push({type:"navigated",url:f,sameDocument:!1})})),g.push(d.on("Page.navigatedWithinDocument",(u)=>{if(o&&u.frameId!==o)return;f=u.url,a.push({type:"navigated",url:u.url,sameDocument:!0})})),await d.send("Page.enable"),o=(await d.send("Page.getFrameTree").catch(()=>null))?.frameTree?.frame?.id,t.viewport)await d.send("Emulation.setDeviceMetricsOverride",{deviceScaleFactor:1,mobile:!1,...t.viewport});await t.beforeNavigate?.(d);let x=await Z(d,e,t);return await y(x)}catch(d){return await y(!1,[`audit: ${d?.message??String(d)}`])}finally{for(let d of g)d();await p?.close().catch(()=>{})}}function A(e){try{let t=new URL(e);return`${t.pathname}${t.search}`}catch{return e}}function ue(e){let t=A(e.url),r=e.finalUrl?A(e.finalUrl):t,n=Object.entries(e.duplicates).map(([s,i])=>`${i}x ${s}`);return`${t}${r!==t?` -> ${r}`:""}: ${e.apiRequests.length} api${n.length?` | dup ${n.join(", ")}`:""}${e.failed.length?` | failed ${e.failed.join(", ")}`:""}${e.errors.length?` | errors ${e.errors.slice(0,3).join(" ; ")}`:""}${e.settled?"":" | never settled"}`}function le(e){return e.settled&&!Object.keys(e.duplicates).length&&!e.failed.length&&!e.errors.length}export{O as Cdp,z as LIVE_RELOAD_PATTERNS,ce as auditPage,G as chromeHasUpdater,j as connect,te as duplicateRequests,W as findChrome,ue as formatPageAudit,Q as launch,ne as loadDuplicates,H as matchesUrlPattern,Z as navigate,le as pageAuditClean,F as pausedRequestMatches,K as prepareMacChrome,J as quietLiveReload,X as settle,re as splitPageLoads,S as waitFor};
@@ -0,0 +1,42 @@
1
+ /** The Postgres server URL disposable databases are created on, if one is configured. */
2
+ export declare function disposablePostgresUrl(options?: Pick<DisposableDatabaseOptions, 'url'>): string | undefined;
3
+ /** Throws unless `url` is a Postgres URL for a server on this machine. */
4
+ export declare function assertLocalPostgres(url: string): URL;
5
+ /**
6
+ * Create a disposable database and return it with a `drop()` (also an
7
+ * `await using` disposer). Prefer `withDisposableDatabase`, which cannot
8
+ * forget to drop it.
9
+ */
10
+ export declare function createDisposableDatabase(dialect: DisposableDialect, options?: DisposableDatabaseOptions): Promise<CreatedDisposableDatabase>;
11
+ /**
12
+ * Run `fn` against a fresh database, then drop it, even when `fn` throws.
13
+ * A failure to drop is reported only when `fn` itself succeeded, so the
14
+ * test's own error is never hidden behind a cleanup error.
15
+ *
16
+ * @example
17
+ * for (const dialect of ['sqlite', 'postgres'] as const) {
18
+ * test.skipIf(dialect === 'postgres' && !disposablePostgresUrl())(`${dialect} checkout flow`, async () => {
19
+ * await withDisposableDatabase(dialect, async (database) => {
20
+ * const result = await runIsolated('tests/fixtures/checkout-flow.ts', { env: { APP_ENV: 'test', ...database.env }, timeoutMs: 45_000 })
21
+ * assertIsolatedOk(result, 'checkout flow OK')
22
+ * })
23
+ * }, 50_000)
24
+ * }
25
+ */
26
+ export declare function withDisposableDatabase<T>(dialect: DisposableDialect, fn: (database: DisposableDatabase) => Promise<T> | T, options?: DisposableDatabaseOptions): Promise<T>;
27
+ export declare interface DisposableDatabaseOptions {
28
+ url?: string
29
+ prefix?: string
30
+ }
31
+ export declare interface DisposableDatabase {
32
+ dialect: DisposableDialect
33
+ name: string
34
+ directory: string
35
+ path?: string
36
+ url?: string
37
+ env: Record<string, string>
38
+ }
39
+ export declare interface CreatedDisposableDatabase extends DisposableDatabase, AsyncDisposable {
40
+ drop: () => Promise<void>
41
+ }
42
+ export type DisposableDialect = 'sqlite' | 'postgres';
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ import{mkdtempSync as u,rmSync as i}from"fs";import{tmpdir as f}from"os";import{join as b}from"path";import m from"process";var{SQL:d}=globalThis.Bun;var g=new Set(["localhost","127.0.0.1","[::1]","::1"]);function S(e={}){return e.url||m.env.STACKS_TEST_POSTGRES_URL||void 0}function w(e){let s;try{s=new URL(e)}catch{throw Error("A disposable Postgres database needs a postgres:// URL")}if(!["postgres:","postgresql:"].includes(s.protocol))throw Error(`A disposable Postgres database needs a postgres:// URL, got ${s.protocol}`);if(!g.has(s.hostname))throw Error(`Disposable databases are only created on a local PostgreSQL server (localhost, 127.0.0.1, ::1), not "${s.hostname}"`);return s}function T(e){return`${(e.toLowerCase().replace(/[^a-z0-9_]/g,"_").replace(/^[^a-z_]+/,"")||"stacks_test").slice(0,38)}_${crypto.randomUUID().replaceAll("-","").slice(0,24)}`}async function h(e,s={}){if(e!=="sqlite"&&e!=="postgres")throw Error(`Disposable databases are sqlite or postgres, not "${String(e)}"`);let r=T(s.prefix??"stacks_test"),t=u(b(f(),`${r.slice(0,20)}-`)),n=!1;if(e==="sqlite"){let o=b(t,`${r}.sqlite`),a=async()=>{if(n)return;n=!0,i(t,{recursive:!0,force:!0})};return{dialect:e,name:r,directory:t,path:o,env:{DB_CONNECTION:"sqlite",DB_DATABASE_PATH:o},drop:a,[Symbol.asyncDispose]:a}}try{let o=S(s);if(!o)throw Error("A disposable Postgres database needs a server: pass options.url or set STACKS_TEST_POSTGRES_URL");let a=w(o),p=new d(a.href);try{await p.unsafe(`CREATE DATABASE "${r}"`)}finally{await p.close()}let c=new URL(a.href);c.pathname=`/${r}`;let l=async()=>{if(n)return;n=!0;let D=new d(a.href);try{await D.unsafe(`DROP DATABASE IF EXISTS "${r}" WITH (FORCE)`)}finally{await D.close(),i(t,{recursive:!0,force:!0})}};return{dialect:e,name:r,directory:t,url:c.href,env:{DB_CONNECTION:"postgres",DB_HOST:a.hostname.replace(/^\[|\]$/g,""),DB_PORT:a.port||"5432",DB_DATABASE:r,DB_USERNAME:decodeURIComponent(a.username),DB_PASSWORD:decodeURIComponent(a.password)},drop:l,[Symbol.asyncDispose]:l}}catch(o){throw i(t,{recursive:!0,force:!0}),o}}async function O(e,s,r={}){let t=await h(e,r),n;try{n=await s(t)}catch(o){throw await t.drop().catch(()=>{}),o}return await t.drop(),n}export{w as assertLocalPostgres,h as createDisposableDatabase,S as disposablePostgresUrl,O as withDisposableDatabase};
package/dist/index.d.ts CHANGED
@@ -27,4 +27,8 @@ export * from './events';
27
27
  // Mail fakes (stacksjs/stacks#2581), on top of the email package's capture driver.
28
28
  export * from './mail';
29
29
  export * from './time';
30
+ // Child-process isolation and throwaway databases. Bun and node only, no
31
+ // framework import, so both are safe at the root.
32
+ export * from './isolated';
33
+ export * from './disposable-database';
30
34
  export * from 'bun:test';
package/dist/index.js CHANGED
@@ -1,3 +1,12 @@
1
1
  // @bun
2
- import R from"process";function z(){R.env.NODE_ENV="test",R.env.APP_ENV="test"}var b=new Set(["headers","query","body","formData","actingAs"]);function q(e){if(!e)return"";let t=new URLSearchParams;for(let[s,o]of Object.entries(e)){if(o===void 0||o===null)continue;if(Array.isArray(o)){for(let u of o)t.append(s,String(u));continue}t.append(s,String(o))}let n=t.toString();return n?`?${n}`:""}function D(e,t,n){let s=Object.keys(e).filter((o)=>!b.has(o));if(s.length===0)return;throw TypeError(`http.${t.toLowerCase()}('${n}', \u2026) got unknown option(s) ${s.map((o)=>`'${o}'`).join(", ")}. Did you mean to send a body? Wrap it: { body: { ${s[0]}: \u2026 } }. `+`Valid options are ${[...b].join(", ")}.`)}async function S(){let e=await import("@stacksjs/router");return e.serverResponse}function N(e){let t={status:e.status,ok:e.ok,headers:e.headers,text:()=>e.text(),json:async()=>await e.clone().json(),assertStatus(n){if(this.status!==n)throw Error(`Expected status ${n}, got ${this.status}`);return t},async assertJson(n){let s=await e.clone().json();for(let[o,u]of Object.entries(n))if(JSON.stringify(s[o])!==JSON.stringify(u))throw Error(`assertJson: expected ${o}=${JSON.stringify(u)}, got ${JSON.stringify(s[o])}`);return t},assertHeader(n,s){let o=e.headers.get(n);if(o===null)throw Error(`assertHeader: missing header '${n}'`);if(s!==void 0){if(!(typeof s==="string"?o===s:s.test(o)))throw Error(`assertHeader: expected ${n}=${String(s)}, got '${o}'`)}return t}};return t}function k(e="http://localhost"){let t=null,n={},s=null;async function o(){if(!t)return null;if(s)return s;let{Auth:r}=await import("@stacksjs/auth"),i=Number(t.id);if(!Number.isFinite(i))throw TypeError(`actingAs() needs a user with a numeric id; got ${JSON.stringify(t.id)}`);let d=await r.loginUsingId(i);if(!d)throw Error(`actingAs() could not authenticate user ${i}: no such user. Create the row first (e.g. via a factory) so a token can be issued against it.`);return s=String(d.token),s}async function u(r){let i={Accept:"application/json",...n,...r.headers};if(r.body!==void 0&&r.formData===void 0)i["Content-Type"]="application/json";let d=await o();if(d&&!i.Authorization&&!i.authorization)i.Authorization=`Bearer ${d}`;return i}function l(r){if(r.formData!==void 0){if(r.body!==void 0)throw TypeError("A request carries either `body` or `formData`, not both.");let i=new FormData;for(let[d,m]of Object.entries(r.formData))i.append(d,m);return i}if(r.body===void 0)return;return typeof r.body==="string"?r.body:JSON.stringify(r.body)}async function c(r,i,d={}){let m=await S(),w=i.startsWith("http")?i:`${e}${i}`,y=q(d.query),P=y&&w.includes("?")?`${w}&${y.slice(1)}`:`${w}${y}`,C={method:r,headers:await u(d),body:l(d)},A=await m(new Request(P,C));return N(A)}let a={actingAs(r){return t=r,s=null,a},withHeaders(r){return n={...n,...r},a},request:(r,i,d)=>c(r,i,d),get:(r)=>c("GET",r),post:(r,i)=>c("POST",r,{body:i}),put:(r,i)=>c("PUT",r,{body:i}),patch:(r,i)=>c("PATCH",r,{body:i}),delete:(r,i)=>c("DELETE",r,{body:i})};return a}function Q(e,t){return k(t).actingAs(e)}var G=Object.freeze(["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"].reduce((e,t)=>{let n=async(s,o={})=>{D(o,t,s);let{actingAs:u,...l}=o,c=k();if(u)c.actingAs(u);return c.request(t,s,l)};return{...e,[t.toLowerCase()]:n}},{}));import x from"process";var j=60000;function H(e){let t=[],n="",s=null,o=!1,u=!1;for(let l of e){if(o){n+=l,o=!1;continue}if(l==="\\"){o=!0,u=!0;continue}if(s){if(l===s)s=null;else n+=l;continue}if(l==='"'||l==="'"){s=l,u=!0;continue}if(/\s/.test(l)){if(u)t.push(n),n="",u=!1;continue}n+=l,u=!0}if(s)throw Error(`Unterminated ${s} in command: ${e}`);if(u)t.push(n);return t}function Y(e){let t=typeof e==="string"?H(e):[...e],n,s={},o=x.cwd(),u=j;async function l(){if(t.length===0)throw Error("command() needs something to run");let a=Bun.spawn(["./buddy",...t],{cwd:o,env:{...x.env,...s},stdin:n===void 0?"ignore":new TextEncoder().encode(n),stdout:"pipe",stderr:"pipe"}),r=!1,i=setTimeout(()=>{r=!0,a.kill()},u);try{let[d,m,w]=await Promise.all([new Response(a.stdout).text(),new Response(a.stderr).text(),a.exited]);return{exitCode:w,stdout:d,stderr:m,output:d+m,timedOut:r}}finally{clearTimeout(i)}}let c={withInput(a){return n=a.map((r)=>`${r}
3
- `).join(""),c},withEnv(a){return s={...s,...a},c},withCwd(a){return o=a,c},withTimeout(a){if(!Number.isFinite(a)||a<=0)throw TypeError(`withTimeout expects a positive number of milliseconds, got ${a}`);return u=a,c},then(a,r){return l().then(a,r)}};return c}import{afterEach as B}from"bun:test";import{events as g}from"@stacksjs/events";var p=null,h=[],E=(e,t)=>{h.push({type:String(e),payload:t})};function te(){if(p)return;p=[...g.all.entries()],g.all.clear(),h=[],g.on("*",E)}function ne(){return p!==null}function L(e){if(!p)throw Error('getDispatchedEvents() was called without eventFake(). Nothing is recording, so an empty result would look like "the event did not fire".');return e===void 0?[...h]:h.filter((t)=>t.type===e)}function re(e){return L(e).length>0}function J(){if(!p)return;g.off("*",E),g.all.clear();for(let[e,t]of p)g.all.set(e,t);p=null,h=[]}B(()=>{J()});import{afterEach as I}from"bun:test";import{CaptureEmailDriver as T,mail as v}from"@stacksjs/email";var f=null;function ae(){if(f!==null)return;f=v.fake(),T.clear()}function ue(){return f!==null}function O(){if(f===null)throw Error('sentEmails() was called without mailFake(). Nothing is capturing, so an empty result would look like "no email was sent".');return[...T.all()]}function de(){return O().at(-1)}function le(e){let t=(n)=>{if(n===void 0||n===null)return!1;return(Array.isArray(n)?n:[n]).some((o)=>(typeof o==="string"?o:o?.address)===e)};return O().filter((n)=>t(n.to)||t(n.cc)||t(n.bcc))}function M(){if(f===null)return;v.restoreDriver(f),f=null,T.clear()}I(()=>{M()});import{setSystemTime as F}from"bun:test";function U(e){let t=e instanceof Date?e:new Date(e);if(Number.isNaN(t.getTime()))throw TypeError(`Not a valid time: ${JSON.stringify(e)}`);return t}function _(e=new Date){let t=U(e);return F(t),t}function fe(e){return _(e)}function me(){F()}export*from"bun:test";export{Q as actingAs,D as assertRequestOptions,Y as command,le as emailsTo,te as eventFake,ne as eventsAreFaked,k as featureTest,_ as freezeTime,L as getDispatchedEvents,re as hasDispatchedEvent,G as http,de as lastEmail,ae as mailFake,ue as mailIsFaked,q as queryString,J as restoreEvents,M as restoreMail,O as sentEmails,z as setupTestEnvironment,H as splitCommand,fe as travelTo,me as useRealTime};
2
+ import D from"process";function me(){D.env.NODE_ENV="test",D.env.APP_ENV="test"}var x=new Set(["headers","query","body","formData","actingAs"]);function L(t){if(!t)return"";let e=new URLSearchParams;for(let[r,s]of Object.entries(t)){if(s===void 0||s===null)continue;if(Array.isArray(s)){for(let a of s)e.append(r,String(a));continue}e.append(r,String(s))}let n=e.toString();return n?`?${n}`:""}function B(t,e,n){let r=Object.keys(t).filter((s)=>!x.has(s));if(r.length===0)return;throw TypeError(`http.${e.toLowerCase()}('${n}', \u2026) got unknown option(s) ${r.map((s)=>`'${s}'`).join(", ")}. Did you mean to send a body? Wrap it: { body: { ${r[0]}: \u2026 } }. `+`Valid options are ${[...x].join(", ")}.`)}async function j(){let t=await import("@stacksjs/router");return t.serverResponse}function U(t){let e={status:t.status,ok:t.ok,headers:t.headers,text:()=>t.text(),json:async()=>await t.clone().json(),assertStatus(n){if(this.status!==n)throw Error(`Expected status ${n}, got ${this.status}`);return e},async assertJson(n){let r=await t.clone().json();for(let[s,a]of Object.entries(n))if(JSON.stringify(r[s])!==JSON.stringify(a))throw Error(`assertJson: expected ${s}=${JSON.stringify(a)}, got ${JSON.stringify(r[s])}`);return e},assertHeader(n,r){let s=t.headers.get(n);if(s===null)throw Error(`assertHeader: missing header '${n}'`);if(r!==void 0){if(!(typeof r==="string"?s===r:r.test(s)))throw Error(`assertHeader: expected ${n}=${String(r)}, got '${s}'`)}return e}};return e}function v(t="http://localhost"){let e=null,n={},r=null;async function s(){if(!e)return null;if(r)return r;let{Auth:o}=await import("@stacksjs/auth"),u=Number(e.id);if(!Number.isFinite(u))throw TypeError(`actingAs() needs a user with a numeric id; got ${JSON.stringify(e.id)}`);let c=await o.loginUsingId(u);if(!c)throw Error(`actingAs() could not authenticate user ${u}: no such user. Create the row first (e.g. via a factory) so a token can be issued against it.`);return r=String(c.token),r}async function a(o){let u={Accept:"application/json",...n,...o.headers};if(o.body!==void 0&&o.formData===void 0)u["Content-Type"]="application/json";let c=await s();if(c&&!u.Authorization&&!u.authorization)u.Authorization=`Bearer ${c}`;return u}function l(o){if(o.formData!==void 0){if(o.body!==void 0)throw TypeError("A request carries either `body` or `formData`, not both.");let u=new FormData;for(let[c,p]of Object.entries(o.formData))u.append(c,p);return u}if(o.body===void 0)return;return typeof o.body==="string"?o.body:JSON.stringify(o.body)}async function d(o,u,c={}){let p=await j(),f=u.startsWith("http")?u:`${t}${u}`,b=L(c.query),I=b&&f.includes("?")?`${f}&${b.slice(1)}`:`${f}${b}`,_={method:o,headers:await a(c),body:l(c)},N=await p(new Request(I,_));return U(N)}let i={actingAs(o){return e=o,r=null,i},withHeaders(o){return n={...n,...o},i},request:(o,u,c)=>d(o,u,c),get:(o)=>d("GET",o),post:(o,u)=>d("POST",o,{body:u}),put:(o,u)=>d("PUT",o,{body:u}),patch:(o,u)=>d("PATCH",o,{body:u}),delete:(o,u)=>d("DELETE",o,{body:u})};return i}function ge(t,e){return v(e).actingAs(t)}var we=Object.freeze(["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"].reduce((t,e)=>{let n=async(r,s={})=>{B(s,e,r);let{actingAs:a,...l}=s,d=v();if(a)d.actingAs(a);return d.request(e,r,l)};return{...t,[e.toLowerCase()]:n}},{}));import k from"process";var H=60000;function M(t){let e=[],n="",r=null,s=!1,a=!1;for(let l of t){if(s){n+=l,s=!1;continue}if(l==="\\"){s=!0,a=!0;continue}if(r){if(l===r)r=null;else n+=l;continue}if(l==='"'||l==="'"){r=l,a=!0;continue}if(/\s/.test(l)){if(a)e.push(n),n="",a=!1;continue}n+=l,a=!0}if(r)throw Error(`Unterminated ${r} in command: ${t}`);if(a)e.push(n);return e}function ye(t){let e=typeof t==="string"?M(t):[...t],n,r={},s=k.cwd(),a=H;async function l(){if(e.length===0)throw Error("command() needs something to run");let i=Bun.spawn(["./buddy",...e],{cwd:s,env:{...k.env,...r},stdin:n===void 0?"ignore":new TextEncoder().encode(n),stdout:"pipe",stderr:"pipe"}),o=!1,u=setTimeout(()=>{o=!0,i.kill()},a);try{let[c,p,f]=await Promise.all([new Response(i.stdout).text(),new Response(i.stderr).text(),i.exited]);return{exitCode:f,stdout:c,stderr:p,output:c+p,timedOut:o}}finally{clearTimeout(u)}}let d={withInput(i){return n=i.map((o)=>`${o}
3
+ `).join(""),d},withEnv(i){return r={...r,...i},d},withCwd(i){return s=i,d},withTimeout(i){if(!Number.isFinite(i)||i<=0)throw TypeError(`withTimeout expects a positive number of milliseconds, got ${i}`);return a=i,d},then(i,o){return l().then(i,o)}};return d}import{afterEach as J}from"bun:test";import{events as w}from"@stacksjs/events";var m=null,h=[],O=(t,e)=>{h.push({type:String(t),payload:e})};function De(){if(m)return;m=[...w.all.entries()],w.all.clear(),h=[],w.on("*",O)}function xe(){return m!==null}function G(t){if(!m)throw Error('getDispatchedEvents() was called without eventFake(). Nothing is recording, so an empty result would look like "the event did not fire".');return t===void 0?[...h]:h.filter((e)=>e.type===t)}function ve(t){return G(t).length>0}function z(){if(!m)return;w.off("*",O),w.all.clear();for(let[t,e]of m)w.all.set(t,e);m=null,h=[]}J(()=>{z()});import{afterEach as Q}from"bun:test";import{CaptureEmailDriver as y,mail as S}from"@stacksjs/email";var g=null;function Ae(){if(g!==null)return;g=S.fake(),y.clear()}function Pe(){return g!==null}function A(){if(g===null)throw Error('sentEmails() was called without mailFake(). Nothing is capturing, so an empty result would look like "no email was sent".');return[...y.all()]}function Ce(){return A().at(-1)}function $e(t){let e=(n)=>{if(n===void 0||n===null)return!1;return(Array.isArray(n)?n:[n]).some((s)=>(typeof s==="string"?s:s?.address)===t)};return A().filter((n)=>e(n.to)||e(n.cc)||e(n.bcc))}function V(){if(g===null)return;S.restoreDriver(g),g=null,y.clear()}Q(()=>{V()});import{setSystemTime as P}from"bun:test";function K(t){let e=t instanceof Date?t:new Date(t);if(Number.isNaN(e.getTime()))throw TypeError(`Not a valid time: ${JSON.stringify(t)}`);return e}function W(t=new Date){let e=K(t);return P(e),e}function Ie(t){return W(t)}function _e(){P()}import{existsSync as X,mkdtempSync as Y,rmSync as Z,writeFileSync as C}from"fs";import{tmpdir as ee}from"os";import{join as T,resolve as te}from"path";import R from"process";var ne=2000;function re(t,e={}){let n=e.mode&&e.mode!=="auto"?e.mode:/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(t)?"test":"run";return[R.execPath,...e.config?[`--config=${e.config}`]:[],...e.envFile===!1?["--no-env-file"]:[],...n==="test"?["test"]:[],t,...e.args??[]]}function se(t){let e=t.inheritEnv===!1?{}:{...R.env};for(let[n,r]of Object.entries(t.env??{}))if(r===void 0)delete e[n];else e[n]=r;return Object.fromEntries(Object.entries(e).filter((n)=>n[1]!==void 0))}async function He(t,e={}){if(!t||typeof t!=="string")throw TypeError("runIsolated needs a file to run");let n=e.timeoutMs??60000;if(!Number.isFinite(n)||n<=0)throw RangeError("timeoutMs must be a positive number of milliseconds");if(e.bunfig!=null&&e.preload)throw TypeError("runIsolated takes bunfig or preload, not both: list the preloads in that bunfig");let r=null,s;if(e.bunfig!=null){if(!e.bunfig)throw TypeError("bunfig must be a path to a bunfig.toml");if(s=te(e.cwd??R.cwd(),e.bunfig),!X(s))throw Error(`runIsolated: no bunfig at ${s}`)}if(e.preload){r=Y(T(ee(),"stacks-isolated-"));let i=T(r,"noop-preload.ts");C(i,`export {}
4
+ `);let o=JSON.stringify(e.preload.length?e.preload:[i]);s=T(r,"bunfig.toml"),C(s,`preload = ${o}
5
+
6
+ [test]
7
+ preload = ${o}
8
+ `)}let a=re(t,{...e,config:s}),l=Date.now(),d=!1;try{let i=Bun.spawn(a,{cwd:e.cwd,env:se(e),stdin:e.stdin!=null?new Blob([e.stdin]):"ignore",stdout:"pipe",stderr:"pipe"}),o,u=setTimeout(()=>{d=!0,i.kill("SIGTERM"),o=setTimeout(()=>i.kill("SIGKILL"),ne)},n);try{let[c,p,f]=await Promise.all([i.exited,new Response(i.stdout).text(),new Response(i.stderr).text()]);return{code:i.signalCode?null:c,signal:i.signalCode==null?null:String(i.signalCode),stdout:p,stderr:f,timedOut:d,durationMs:Date.now()-l,command:a}}finally{if(clearTimeout(u),clearTimeout(o),i.exitCode===null&&!i.signalCode)i.kill("SIGKILL")}}finally{if(r)Z(r,{recursive:!0,force:!0})}}function Me(t,e){let n=`
9
+ --- stdout ---
10
+ ${t.stdout}
11
+ --- stderr ---
12
+ ${t.stderr}`;if(t.timedOut)throw Error(`${t.command.slice(1).join(" ")} timed out after ${t.durationMs}ms${n}`);if(t.code!==0)throw Error(`${t.command.slice(1).join(" ")} exited ${t.code??t.signal}${n}`);if(e!=null&&!(typeof e==="string"?t.stdout.includes(e):e.test(t.stdout)))throw Error(`${t.command.slice(1).join(" ")} did not print ${String(e)}${n}`);return t}import{mkdtempSync as oe,rmSync as E}from"fs";import{tmpdir as ie}from"os";import{join as F}from"path";import ae from"process";var{SQL:q}=globalThis.Bun;var ue=new Set(["localhost","127.0.0.1","[::1]","::1"]);function le(t={}){return t.url||ae.env.STACKS_TEST_POSTGRES_URL||void 0}function de(t){let e;try{e=new URL(t)}catch{throw Error("A disposable Postgres database needs a postgres:// URL")}if(!["postgres:","postgresql:"].includes(e.protocol))throw Error(`A disposable Postgres database needs a postgres:// URL, got ${e.protocol}`);if(!ue.has(e.hostname))throw Error(`Disposable databases are only created on a local PostgreSQL server (localhost, 127.0.0.1, ::1), not "${e.hostname}"`);return e}function ce(t){return`${(t.toLowerCase().replace(/[^a-z0-9_]/g,"_").replace(/^[^a-z_]+/,"")||"stacks_test").slice(0,38)}_${crypto.randomUUID().replaceAll("-","").slice(0,24)}`}async function pe(t,e={}){if(t!=="sqlite"&&t!=="postgres")throw Error(`Disposable databases are sqlite or postgres, not "${String(t)}"`);let n=ce(e.prefix??"stacks_test"),r=oe(F(ie(),`${n.slice(0,20)}-`)),s=!1;if(t==="sqlite"){let a=F(r,`${n}.sqlite`),l=async()=>{if(s)return;s=!0,E(r,{recursive:!0,force:!0})};return{dialect:t,name:n,directory:r,path:a,env:{DB_CONNECTION:"sqlite",DB_DATABASE_PATH:a},drop:l,[Symbol.asyncDispose]:l}}try{let a=le(e);if(!a)throw Error("A disposable Postgres database needs a server: pass options.url or set STACKS_TEST_POSTGRES_URL");let l=de(a),d=new q(l.href);try{await d.unsafe(`CREATE DATABASE "${n}"`)}finally{await d.close()}let i=new URL(l.href);i.pathname=`/${n}`;let o=async()=>{if(s)return;s=!0;let u=new q(l.href);try{await u.unsafe(`DROP DATABASE IF EXISTS "${n}" WITH (FORCE)`)}finally{await u.close(),E(r,{recursive:!0,force:!0})}};return{dialect:t,name:n,directory:r,url:i.href,env:{DB_CONNECTION:"postgres",DB_HOST:l.hostname.replace(/^\[|\]$/g,""),DB_PORT:l.port||"5432",DB_DATABASE:n,DB_USERNAME:decodeURIComponent(l.username),DB_PASSWORD:decodeURIComponent(l.password)},drop:o,[Symbol.asyncDispose]:o}}catch(a){throw E(r,{recursive:!0,force:!0}),a}}async function We(t,e,n={}){let r=await pe(t,n),s;try{s=await e(r)}catch(a){throw await r.drop().catch(()=>{}),a}return await r.drop(),s}export*from"bun:test";export{ge as actingAs,Me as assertIsolatedOk,de as assertLocalPostgres,B as assertRequestOptions,ye as command,pe as createDisposableDatabase,le as disposablePostgresUrl,$e as emailsTo,De as eventFake,xe as eventsAreFaked,v as featureTest,W as freezeTime,G as getDispatchedEvents,ve as hasDispatchedEvent,we as http,re as isolatedCommand,Ce as lastEmail,Ae as mailFake,Pe as mailIsFaked,L as queryString,z as restoreEvents,V as restoreMail,He as runIsolated,A as sentEmails,me as setupTestEnvironment,M as splitCommand,Ie as travelTo,_e as useRealTime,We as withDisposableDatabase};
@@ -0,0 +1,42 @@
1
+ /** The full Bun command line `runIsolated` would spawn for these options. */
2
+ export declare function isolatedCommand(file: string, options?: Pick<RunIsolatedOptions, 'mode' | 'args' | 'envFile'> & { config?: string }): string[];
3
+ /**
4
+ * Run `file` in a fresh Bun process and collect what it printed.
5
+ *
6
+ * @example
7
+ * const result = await runIsolated('tests/fixtures/billing-flow.ts', {
8
+ * env: { APP_ENV: 'test', DB_CONNECTION: 'sqlite', DB_DATABASE_PATH: path },
9
+ * timeoutMs: 45_000,
10
+ * })
11
+ * expect(result.code, result.stdout + result.stderr).toBe(0)
12
+ */
13
+ export declare function runIsolated(file: string, options?: RunIsolatedOptions): Promise<IsolatedResult>;
14
+ /**
15
+ * Throw, with everything the child printed, unless it exited 0 (and printed
16
+ * `expect` to stdout, when given). Returns the result for further checks.
17
+ *
18
+ * @example
19
+ * assertIsolatedOk(await runIsolated('tests/fixtures/flow.ts'), 'flow OK')
20
+ */
21
+ export declare function assertIsolatedOk(result: IsolatedResult, expect?: string | RegExp): IsolatedResult;
22
+ export declare interface RunIsolatedOptions {
23
+ env?: Record<string, string | undefined>
24
+ inheritEnv?: boolean
25
+ timeoutMs?: number
26
+ args?: string[]
27
+ cwd?: string
28
+ mode?: 'auto' | 'test' | 'run'
29
+ preload?: string[]
30
+ bunfig?: string
31
+ envFile?: boolean
32
+ stdin?: string
33
+ }
34
+ export declare interface IsolatedResult {
35
+ code: number | null
36
+ signal: string | null
37
+ stdout: string
38
+ stderr: string
39
+ timedOut: boolean
40
+ durationMs: number
41
+ command: string[]
42
+ }
@@ -0,0 +1,11 @@
1
+ // @bun
2
+ import{existsSync as p,mkdtempSync as h,rmSync as y,writeFileSync as c}from"fs";import{tmpdir as I}from"os";import{join as d,resolve as R}from"path";import l from"process";var v=2000;function E(t,e={}){let n=e.mode&&e.mode!=="auto"?e.mode:/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(t)?"test":"run";return[l.execPath,...e.config?[`--config=${e.config}`]:[],...e.envFile===!1?["--no-env-file"]:[],...n==="test"?["test"]:[],t,...e.args??[]]}function O(t){let e=t.inheritEnv===!1?{}:{...l.env};for(let[n,i]of Object.entries(t.env??{}))if(i===void 0)delete e[n];else e[n]=i;return Object.fromEntries(Object.entries(e).filter((n)=>n[1]!==void 0))}async function M(t,e={}){if(!t||typeof t!=="string")throw TypeError("runIsolated needs a file to run");let n=e.timeoutMs??60000;if(!Number.isFinite(n)||n<=0)throw RangeError("timeoutMs must be a positive number of milliseconds");if(e.bunfig!=null&&e.preload)throw TypeError("runIsolated takes bunfig or preload, not both: list the preloads in that bunfig");let i=null,o;if(e.bunfig!=null){if(!e.bunfig)throw TypeError("bunfig must be a path to a bunfig.toml");if(o=R(e.cwd??l.cwd(),e.bunfig),!p(o))throw Error(`runIsolated: no bunfig at ${o}`)}if(e.preload){i=h(d(I(),"stacks-isolated-"));let r=d(i,"noop-preload.ts");c(r,`export {}
3
+ `);let s=JSON.stringify(e.preload.length?e.preload:[r]);o=d(i,"bunfig.toml"),c(o,`preload = ${s}
4
+
5
+ [test]
6
+ preload = ${s}
7
+ `)}let a=E(t,{...e,config:o}),f=Date.now(),u=!1;try{let r=Bun.spawn(a,{cwd:e.cwd,env:O(e),stdin:e.stdin!=null?new Blob([e.stdin]):"ignore",stdout:"pipe",stderr:"pipe"}),s,g=setTimeout(()=>{u=!0,r.kill("SIGTERM"),s=setTimeout(()=>r.kill("SIGKILL"),v)},n);try{let[m,w,b]=await Promise.all([r.exited,new Response(r.stdout).text(),new Response(r.stderr).text()]);return{code:r.signalCode?null:m,signal:r.signalCode==null?null:String(r.signalCode),stdout:w,stderr:b,timedOut:u,durationMs:Date.now()-f,command:a}}finally{if(clearTimeout(g),clearTimeout(s),r.exitCode===null&&!r.signalCode)r.kill("SIGKILL")}}finally{if(i)y(i,{recursive:!0,force:!0})}}function j(t,e){let n=`
8
+ --- stdout ---
9
+ ${t.stdout}
10
+ --- stderr ---
11
+ ${t.stderr}`;if(t.timedOut)throw Error(`${t.command.slice(1).join(" ")} timed out after ${t.durationMs}ms${n}`);if(t.code!==0)throw Error(`${t.command.slice(1).join(" ")} exited ${t.code??t.signal}${n}`);if(e!=null&&!(typeof e==="string"?t.stdout.includes(e):e.test(t.stdout)))throw Error(`${t.command.slice(1).join(" ")} did not print ${String(e)}${n}`);return t}export{j as assertIsolatedOk,E as isolatedCommand,M as runIsolated};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/testing",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.226",
5
+ "version": "0.75.233",
6
6
  "description": "The Stacks way of testing.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -42,6 +42,12 @@
42
42
  "import": "./dist/database.js",
43
43
  "default": "./dist/database.js"
44
44
  },
45
+ "./browser": {
46
+ "types": "./dist/browser.d.ts",
47
+ "bun": "./dist/browser.js",
48
+ "import": "./dist/browser.js",
49
+ "default": "./dist/browser.js"
50
+ },
45
51
  "./dynamodb": {
46
52
  "types": "./dist/dynamodb.d.ts",
47
53
  "bun": "./dist/dynamodb.js",
@@ -73,16 +79,16 @@
73
79
  "prepublishOnly": "bun run build"
74
80
  },
75
81
  "dependencies": {
76
- "@stacksjs/auth": "0.75.226",
77
- "@stacksjs/cache": "0.75.226",
78
- "@stacksjs/config": "0.75.226",
79
- "@stacksjs/database": "0.75.226",
80
- "@stacksjs/email": "0.75.226",
81
- "@stacksjs/events": "0.75.226",
82
- "@stacksjs/path": "0.75.226",
83
- "@stacksjs/router": "0.75.226",
84
- "@stacksjs/storage": "0.75.226",
85
- "@stacksjs/strings": "0.75.226",
82
+ "@stacksjs/auth": "0.75.233",
83
+ "@stacksjs/cache": "0.75.233",
84
+ "@stacksjs/config": "0.75.233",
85
+ "@stacksjs/database": "0.75.233",
86
+ "@stacksjs/email": "0.75.233",
87
+ "@stacksjs/events": "0.75.233",
88
+ "@stacksjs/path": "0.75.233",
89
+ "@stacksjs/router": "0.75.233",
90
+ "@stacksjs/storage": "0.75.233",
91
+ "@stacksjs/strings": "0.75.233",
86
92
  "@stacksjs/ts-cloud": "^0.16.58",
87
93
  "very-happy-dom": "^0.3.5"
88
94
  },