@specific.dev/spectest 0.15.0 → 0.17.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/browser.ts CHANGED
@@ -159,14 +159,34 @@ export interface Browser {
159
159
  /** Navigate to a URL; resolves when the main frame's load completes. */
160
160
  navigate(url: string): Promise<void>;
161
161
  /**
162
- * Evaluate a JS expression in the page and return the
163
- * JSON-deserialised result.
162
+ * Evaluate JS in the page and return the JSON-deserialised result.
163
+ * Accepts a single expression or a statement body (`const x = …;
164
+ * return x;`) — statement bodies are auto-wrapped in an async IIFE, so
165
+ * `return` and `await` work without manual wrapping.
164
166
  *
165
167
  * `description` is a short human-readable label for what the
166
168
  * snippet is doing ("read rendered todo list"); it surfaces in the
167
169
  * test event log so the step list isn't a wall of minified code.
168
170
  */
169
171
  evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
172
+ /**
173
+ * Install a script that runs in every document loaded from now on, BEFORE
174
+ * any of the document's own scripts execute (CDP
175
+ * `Page.addScriptToEvaluateOnNewDocument` — the Playwright `addInitScript`
176
+ * equivalent). The deterministic way to plant shims and instrumentation
177
+ * (`fetch`/`XMLHttpRequest` wrappers, clock stubs, feature flags): unlike an
178
+ * `evaluate` racing the app bundle after a navigation, an init script is
179
+ * guaranteed to win.
180
+ *
181
+ * Does NOT run in the *current* document — call it before the `navigate`
182
+ * (or in-page `location.assign`) whose document needs it. Installed
183
+ * scripts persist for the browser session's lifetime, which for the
184
+ * persistent `ctx.browser()`/`ctx.mobile()` sessions means they ride
185
+ * snapshots into `dependsOn` children like the rest of the session state.
186
+ *
187
+ * `description` labels the step in the test event log.
188
+ */
189
+ addInitScript(description: string, source: string): Promise<void>;
170
190
  /**
171
191
  * Poll `expression` in the page until it returns a truthy value
172
192
  * (the returned value is what `waitFor` resolves with). Useful for
@@ -233,8 +253,13 @@ export interface Browser {
233
253
  * drain as the desktop verbs.
234
254
  */
235
255
  export interface MobileBackend extends Browser {
236
- /** Touch-tap at viewport CSS coordinates (touchStart→touchEnd). */
237
- tapAt(x: number, y: number): Promise<void>;
256
+ /** Safe-area insets emulated on this view (`null` on desktop views or
257
+ * when the CDP override is unavailable). The daemon stamps these onto
258
+ * the session record for the dashboard's replay. */
259
+ readonly safeAreaInsets: SafeAreaInsets | null;
260
+ /** Touch-tap at viewport CSS coordinates (touchStart → short dwell →
261
+ * touchEnd; `durationMs` overrides the dwell). */
262
+ tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
238
263
  /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
239
264
  swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
240
265
  /**
@@ -267,6 +292,32 @@ const CHROME_ARGV = [
267
292
  "--dns-over-https-mode=off",
268
293
  ];
269
294
 
295
+ /** Default touchStart→touchEnd dwell for `tapAt` — see the comment there. */
296
+ const TAP_DWELL_MS = 60;
297
+
298
+ /**
299
+ * Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
300
+ * single EXPRESSION (it wraps the source in `await (...)`), so a statement
301
+ * body (`const x = …; return x;`) is a syntax error. Rather than forcing
302
+ * authors to IIFE-wrap by hand, wrap it for them when it isn't an expression.
303
+ *
304
+ * The check is parse-only (`new Function` compiles without executing) and
305
+ * happens BEFORE the script runs — deciding by catching the page-side error
306
+ * and retrying would re-execute side-effecting expressions whose *runtime*
307
+ * error happens to look syntactic (`JSON.parse` throws SyntaxError too).
308
+ */
309
+ function toEvaluable(script: string): string {
310
+ try {
311
+ new Function(`return (${script}\n);`);
312
+ return script;
313
+ } catch {
314
+ // Statement body — an async IIFE makes `return`, declarations, and
315
+ // multi-statement snippets valid, with `await` still available. The
316
+ // newlines keep a trailing line comment from eating the wrapper.
317
+ return `(async () => {\n${script}\n})()`;
318
+ }
319
+ }
320
+
270
321
  // ────────────────────────────────────────────────────────────────────────
271
322
  // Device emulation (mobile frame)
272
323
  // ────────────────────────────────────────────────────────────────────────
@@ -281,6 +332,15 @@ interface DevicePreset {
281
332
  isMobile: boolean;
282
333
  hasTouch: boolean;
283
334
  userAgent: string;
335
+ safeAreaInsets: SafeAreaInsets;
336
+ }
337
+
338
+ /** iOS safe-area insets (CSS `env(safe-area-inset-*)`), in CSS px. */
339
+ export interface SafeAreaInsets {
340
+ top: number;
341
+ right: number;
342
+ bottom: number;
343
+ left: number;
284
344
  }
285
345
 
286
346
  /** The fixed mobile device. Logical resolution + DPR of a current iPhone;
@@ -296,16 +356,28 @@ const LATEST_IPHONE: DevicePreset = {
296
356
  userAgent:
297
357
  "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
298
358
  "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
359
+ // Portrait safe area of the 393×852 iPhones (14 Pro through 16): 59pt
360
+ // status-bar/Dynamic-Island clearance on top, 34pt home-indicator strip
361
+ // at the bottom. Emulated via CDP so the app's `env(safe-area-inset-*)`
362
+ // padding fires exactly like on the real device.
363
+ safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
299
364
  };
300
365
 
301
366
  /** Apply CDP device emulation to a freshly-created view. The overrides are
302
367
  * CDP-session-global, so they persist across the app navigation that
303
368
  * follows (we set them on the about:blank bootstrap page). Best-effort:
304
- * a CDP failure degrades to a plain desktop view rather than aborting. */
369
+ * a CDP failure degrades to a plain desktop view rather than aborting.
370
+ *
371
+ * Returns the safe-area insets that actually took effect (`null` when the
372
+ * override failed — Chromium < 135 lacks the CDP method). The caller
373
+ * stamps them onto the session record so the dashboard can substitute the
374
+ * same values for `env(safe-area-inset-*)` in the replayed CSS; stamping
375
+ * only what was really applied keeps capture layout and replay layout in
376
+ * lockstep (recorded touch coordinates would misalign otherwise). */
305
377
  async function applyDeviceEmulation(
306
378
  view: BunWebViewInstance,
307
379
  d: DevicePreset,
308
- ): Promise<void> {
380
+ ): Promise<SafeAreaInsets | null> {
309
381
  try {
310
382
  await view.cdp("Emulation.setDeviceMetricsOverride", {
311
383
  width: d.viewport.width,
@@ -323,6 +395,17 @@ async function applyDeviceEmulation(
323
395
  } catch (err) {
324
396
  // eslint-disable-next-line no-console
325
397
  console.warn("[spectest] device emulation failed; using desktop view:", err);
398
+ return null;
399
+ }
400
+ try {
401
+ await view.cdp("Emulation.setSafeAreaInsetsOverride", {
402
+ insets: { ...d.safeAreaInsets },
403
+ });
404
+ return d.safeAreaInsets;
405
+ } catch (err) {
406
+ // eslint-disable-next-line no-console
407
+ console.warn("[spectest] safe-area inset emulation unavailable:", err);
408
+ return null;
326
409
  }
327
410
  }
328
411
 
@@ -632,6 +715,13 @@ interface ViewHolder {
632
715
  device: DevicePreset | null;
633
716
  width: number;
634
717
  height: number;
718
+ /** Safe-area insets actually applied to the view (`null` for desktop
719
+ * views or when the CDP override is unavailable). Stamped onto the
720
+ * session record so the replay can mirror them. */
721
+ safeAreaInsets: SafeAreaInsets | null;
722
+ /** User scripts installed via `addInitScript`, kept so the DNS-recovery
723
+ * rebuild can re-install them on the replacement view. */
724
+ initScripts: string[];
635
725
  }
636
726
 
637
727
  // Pre-opened view pool. Renderer spawn is the expensive part of
@@ -725,9 +815,11 @@ export async function openMobileBackend(
725
815
  device,
726
816
  width: wantW,
727
817
  height: wantH,
818
+ safeAreaInsets: null,
819
+ initScripts: [],
728
820
  };
729
821
 
730
- if (device) await applyDeviceEmulation(holder.view, device);
822
+ if (device) holder.safeAreaInsets = await applyDeviceEmulation(holder.view, device);
731
823
 
732
824
  const { backend } = buildBackend(holder, opts.recorder ?? null, {
733
825
  persistent: false,
@@ -795,8 +887,16 @@ async function newHolder(
795
887
  device: DevicePreset | null,
796
888
  ): Promise<ViewHolder> {
797
889
  const { view, recordingInstalled } = await createView(width, height);
798
- const holder: ViewHolder = { view, recordingInstalled, device, width, height };
799
- if (device) await applyDeviceEmulation(view, device);
890
+ const holder: ViewHolder = {
891
+ view,
892
+ recordingInstalled,
893
+ device,
894
+ width,
895
+ height,
896
+ safeAreaInsets: null,
897
+ initScripts: [],
898
+ };
899
+ if (device) holder.safeAreaInsets = await applyDeviceEmulation(view, device);
800
900
  return holder;
801
901
  }
802
902
 
@@ -916,7 +1016,12 @@ async function rebuildView(holder: ViewHolder): Promise<void> {
916
1016
  const fresh = await createView(holder.width, holder.height);
917
1017
  holder.view = fresh.view;
918
1018
  holder.recordingInstalled = fresh.recordingInstalled;
919
- if (holder.device) await applyDeviceEmulation(holder.view, holder.device);
1019
+ if (holder.device) {
1020
+ holder.safeAreaInsets = await applyDeviceEmulation(holder.view, holder.device);
1021
+ }
1022
+ for (const source of holder.initScripts) {
1023
+ await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", { source });
1024
+ }
920
1025
  }
921
1026
 
922
1027
  interface BackendBuildOptions {
@@ -1032,6 +1137,9 @@ function buildBackend(
1032
1137
  get title() {
1033
1138
  return holder.view.title;
1034
1139
  },
1140
+ get safeAreaInsets() {
1141
+ return holder.safeAreaInsets;
1142
+ },
1035
1143
  navigate(url) {
1036
1144
  recorder?.noteNavigation?.(url);
1037
1145
  return instrumented("navigate", { url }, async () => {
@@ -1065,11 +1173,29 @@ function buildBackend(
1065
1173
  scriptTruncated: truncatedScript.truncated,
1066
1174
  },
1067
1175
  async () => {
1068
- const v = await holder.view.evaluate<T>(script);
1176
+ const v = await holder.view.evaluate<T>(toEvaluable(script));
1069
1177
  return v;
1070
1178
  },
1071
1179
  ) as Promise<Wrapped<T>>;
1072
1180
  },
1181
+ addInitScript(description: string, source: string): Promise<void> {
1182
+ const truncated = truncateUtf8(source);
1183
+ return instrumented(
1184
+ "addInitScript",
1185
+ {
1186
+ description,
1187
+ script: truncated.value,
1188
+ scriptTruncated: truncated.truncated,
1189
+ },
1190
+ async () => {
1191
+ await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", {
1192
+ source,
1193
+ });
1194
+ // Remember it so a DNS-recovery view rebuild re-installs it.
1195
+ holder.initScripts.push(source);
1196
+ },
1197
+ );
1198
+ },
1073
1199
  async waitFor<T = unknown>(
1074
1200
  description: string,
1075
1201
  expression: string,
@@ -1086,6 +1212,8 @@ function buildBackend(
1086
1212
  scriptTruncated: truncatedScript.truncated,
1087
1213
  attempts: 0,
1088
1214
  };
1215
+ // Normalised once up front — see `toEvaluable` (statement bodies work).
1216
+ const evaluable = toEvaluable(expression);
1089
1217
  return instrumented<T>("waitFor", fields, async () => {
1090
1218
  const deadline = Date.now() + timeoutMs;
1091
1219
  // (return value wrapped by `instrumented`; cast below matches.)
@@ -1098,7 +1226,7 @@ function buildBackend(
1098
1226
  fields.attempts = (fields.attempts ?? 0) + 1;
1099
1227
  let v: unknown;
1100
1228
  try {
1101
- v = await holder.view.evaluate<unknown>(expression);
1229
+ v = await holder.view.evaluate<unknown>(evaluable);
1102
1230
  } catch (err) {
1103
1231
  if (Date.now() >= deadline) throw err;
1104
1232
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -1175,7 +1303,7 @@ function buildBackend(
1175
1303
  }
1176
1304
  },
1177
1305
  // ── Mobile-only primitives ──────────────────────────────────────────
1178
- tapAt(x, y) {
1306
+ tapAt(x, y, opts) {
1179
1307
  // Recorded as a "click" (the event schema stays desktop-shaped; the
1180
1308
  // mobile facade owns the author-facing "tap" vocabulary). Dispatches
1181
1309
  // a real touch so RN-Web's responder system fires.
@@ -1184,6 +1312,13 @@ function buildBackend(
1184
1312
  type: "touchStart",
1185
1313
  touchPoints: [{ x, y, id: 0 }],
1186
1314
  });
1315
+ // Dwell between start and end, like a real finger. An instant
1316
+ // touchStart→touchEnd starves RN Pressables whose `onPressIn`
1317
+ // mutates state (optimistic label flips, scale animations): React
1318
+ // re-renders mid-gesture and the press never completes. The dwell
1319
+ // lets that commit land before release; well under any long-press
1320
+ // threshold (RN default 500ms).
1321
+ await new Promise((r) => setTimeout(r, opts?.durationMs ?? TAP_DWELL_MS));
1187
1322
  await holder.view.cdp("Input.dispatchTouchEvent", {
1188
1323
  type: "touchEnd",
1189
1324
  touchPoints: [],
@@ -176,6 +176,17 @@ export function email(opts: EmailOptions = {}) {
176
176
  } satisfies ServiceDefinition<EmailHelpers>;
177
177
  }
178
178
 
179
+ /**
180
+ * Build the mailbox helpers for an `email()` service, by its services-map
181
+ * key. For components that expose a shared mailbox on their own handle
182
+ * (e.g. `supabase({ mail: true })` surfacing `ctx.svc.supabase.mail`) —
183
+ * the recorded `email` events carry `service` so the dashboard attributes
184
+ * them to the right container.
185
+ */
186
+ export function emailHelpers(service: string, apiPort: number = API_PORT): EmailHelpers {
187
+ return buildHelpers(service, `http://${service}:${apiPort}`);
188
+ }
189
+
179
190
  function buildHelpers(service: string, base: string): EmailHelpers {
180
191
  return {
181
192
  async lastEmail() {
@@ -28,7 +28,12 @@ export interface ExpoOptions {
28
28
  nodeVersion?: string;
29
29
  /** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
30
30
  outputDir?: string;
31
- /** Extra environment variables for the static server container. */
31
+ /**
32
+ * Extra environment variables. `EXPO_PUBLIC_*` keys are additionally baked
33
+ * into the image build so `expo export` inlines them into the bundle —
34
+ * Expo resolves them at export time, so runtime-only container env would be
35
+ * too late. Everything else applies to the static-server container only.
36
+ */
32
37
  env?: Record<string, string>;
33
38
  }
34
39
 
@@ -106,6 +111,23 @@ export function expo(opts: ExpoOptions = {}) {
106
111
  const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
107
112
  const outputDir = opts.outputDir ?? "dist";
108
113
 
114
+ // Expo inlines EXPO_PUBLIC_* variables into the bundle at `expo export`
115
+ // time, so they must exist during the image build — runtime container env
116
+ // is too late. Bake them as ENV lines placed after `npm install` (an env
117
+ // edit re-exports without re-installing) and before the export RUN. They
118
+ // remain in `env` too, so server-side reads in the container agree.
119
+ const publicEnv = Object.entries(opts.env ?? {}).filter(([k]) =>
120
+ k.startsWith("EXPO_PUBLIC_"),
121
+ );
122
+ for (const [k, v] of publicEnv) {
123
+ if (/[\r\n]/.test(v)) {
124
+ throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
125
+ }
126
+ }
127
+ const publicEnvLines = publicEnv
128
+ .map(([k, v]) => `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
129
+ .join("\n");
130
+
109
131
  // CI=1 keeps `expo export` non-interactive. Static export resolves the web
110
132
  // bundle deterministically into `outputDir`.
111
133
  const dockerfile = `FROM node:${nodeVersion}
@@ -113,7 +135,7 @@ WORKDIR /app
113
135
  ENV CI=1
114
136
  COPY ${appDir}/ ./
115
137
  RUN npm install
116
- RUN npx expo export --platform web --output-dir ${outputDir}
138
+ ${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web --output-dir ${outputDir}
117
139
  `;
118
140
 
119
141
  return {
@@ -63,6 +63,7 @@ import {
63
63
  type ServicesMap,
64
64
  } from "../index.js";
65
65
  import { SQL, type SqlClient } from "../sql.js";
66
+ import { email, emailHelpers, type EmailHelpers } from "./email.js";
66
67
 
67
68
  // ──────────────────────────────────────────────────────────────────────────
68
69
  // Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
@@ -132,12 +133,45 @@ export interface SupabaseOptions {
132
133
  */
133
134
  hostname?: string;
134
135
 
136
+ /**
137
+ * Capture GoTrue's outgoing mail (magic links, OTPs, password recovery,
138
+ * invites — and signup confirmations with `autoconfirm: false`) with the
139
+ * standard `email()` component. **On by default**: the stack includes the
140
+ * capture server (`<name>-mail`) with GoTrue's SMTP wired at it, and tests
141
+ * read the mailbox through the typed helpers at `ctx.svc.<name>.mail`
142
+ * (`lastEmail` / `emails` / `clear`), each captured message rendering
143
+ * mail-client-style on the dashboard timeline.
144
+ *
145
+ * Signups stay one-step by default (`autoconfirm: true`) so the mailbox is
146
+ * purely additive; pass `{ autoconfirm: false }` to test real
147
+ * email-confirmation flows. Pass `{ service: "<key>" }` to point GoTrue at
148
+ * an `email()` service the environment already declares — keep a single
149
+ * mailbox per environment rather than one per component: if the app under
150
+ * test also sends mail, share one `email()` between it and Supabase
151
+ * instead of standing up a second capture server. `false` disables mail
152
+ * capture entirely.
153
+ */
154
+ mail?: boolean | SupabaseMailOptions;
155
+
156
+ /**
157
+ * Extra API keys the gateway accepts alongside the derived JWT keys — for
158
+ * clients that ship a hardcoded key (e.g. an `sb_publishable_…` /
159
+ * `sb_secret_…` pair baked into an app build). Each `anon` key is admitted
160
+ * with anon privileges and each `serviceRole` key with service-role
161
+ * privileges; Kong swaps the matching *derived JWT* in before proxying, so
162
+ * GoTrue/PostgREST/Realtime still receive a validly-signed token.
163
+ */
164
+ extraApiKeys?: { anon?: string[]; serviceRole?: string[] };
165
+
135
166
  /**
136
167
  * Auto-apply SQL migrations found in the project before any dependent service
137
168
  * starts. `true` (default) applies every `*.sql` under `supabase/migrations/`
138
169
  * (sorted, the Supabase convention), then `supabase/seed.sql` if present — a
139
170
  * no-op when the directory is absent. Pass a string to point at a different
140
171
  * directory (relative to the project root or absolute), or `false` to skip.
172
+ * An *explicitly configured* path that doesn't resolve is an error at env
173
+ * start (a silent no-op there surfaces much later as a confusing
174
+ * `relation … does not exist`).
141
175
  * Because `supabase/**` is project content, editing a migration correctly
142
176
  * forces a cold rebuild and re-apply (unlike `spectest/tests/**`).
143
177
  */
@@ -150,6 +184,28 @@ export interface SupabaseOptions {
150
184
  seed?: boolean | string;
151
185
  }
152
186
 
187
+ /** Tuning for `SupabaseOptions.mail`. */
188
+ export interface SupabaseMailOptions {
189
+ /**
190
+ * Reuse an `email()` service the environment already declares (its
191
+ * services-map key) instead of adding one to the group — the way to keep a
192
+ * single global mailbox when the app under test sends mail too. Assumes
193
+ * the service's default ports (SMTP 1025, API 8025).
194
+ */
195
+ service?: string;
196
+ /**
197
+ * Whether GoTrue autoconfirms signups. `true` (default): signups complete
198
+ * in one step and send no confirmation mail — recovery, magic-link and OTP
199
+ * mail is still sent and captured. Set `false` to exercise real
200
+ * email-confirmation flows (signups require the emailed verification).
201
+ */
202
+ autoconfirm?: boolean;
203
+ /** Sender address GoTrue mails from. Default `admin@example.com`. */
204
+ adminEmail?: string;
205
+ /** Sender display name. Default `Supabase`. */
206
+ senderName?: string;
207
+ }
208
+
153
209
  /** Helpers the gateway service exposes on `ctx.svc.<name>`. */
154
210
  export interface SupabaseHelpers {
155
211
  /**
@@ -165,6 +221,18 @@ export interface SupabaseHelpers {
165
221
  anonKey: string;
166
222
  /** Long-lived `service_role` API key (HS256 JWT). Bypasses RLS — server-side only. */
167
223
  serviceRoleKey: string;
224
+ /**
225
+ * The captured-mail mailbox (`lastEmail` / `emails` / `clear`) — present
226
+ * unless the stack was built with `mail: false`. Wait for delivery with
227
+ * the standard `ctx.poll`:
228
+ *
229
+ * ```ts
230
+ * const mail = await ctx.poll("confirmation email", () =>
231
+ * ctx.svc.supabase.mail!.lastEmail());
232
+ * expect(mail.to).toContain("alice@example.com");
233
+ * ```
234
+ */
235
+ mail?: EmailHelpers;
168
236
  }
169
237
 
170
238
  /** What `supabase()` returns: the service group plus the derived
@@ -185,6 +253,12 @@ export interface SupabaseStack {
185
253
  /** The gateway service key — use in a dependent service's `dependsOn` to wait
186
254
  * for the whole stack (the gateway waits on the services behind it). */
187
255
  ready: string;
256
+ /**
257
+ * Where the stack's SMTP capture server listens (absent with
258
+ * `mail: false`) — point the app under test's own mail transport here to
259
+ * share the one mailbox: `env: { SMTP_HOST: sb.smtp.host, SMTP_PORT: String(sb.smtp.port) }`.
260
+ */
261
+ smtp?: { host: string; port: number };
188
262
  /** Ready-to-spread env for the app under test: `SUPABASE_URL`,
189
263
  * `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`. */
190
264
  appEnv: Record<string, string>;
@@ -277,12 +351,32 @@ function buildBootstrapSql(
277
351
  // ──────────────────────────────────────────────────────────────────────────
278
352
 
279
353
  // The two Lua router expressions kong-entrypoint.sh emits when the opaque
280
- // `sb_`-style keys are NOT configured (our case): pass the incoming apikey (a
281
- // legacy HS256 JWT) through unchanged, preferring an existing non-`sb_`
282
- // Authorization header.
283
- const LUA_AUTH_EXPR =
284
- "$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)";
285
- const LUA_RT_WS_EXPR = "$(query_params.apikey)";
354
+ // `sb_`-style keys are NOT configured: pass the incoming apikey (a legacy
355
+ // HS256 JWT) through unchanged, preferring an existing non-`sb_`
356
+ // Authorization header. `extraApiKeys` extends both: an extra key (arriving
357
+ // as `apikey` header, `Authorization: Bearer <key>`, or the websocket
358
+ // `?apikey=` query param) is swapped for the corresponding *derived JWT*
359
+ // before proxying, so the upstream services still see a validly-signed token.
360
+ function buildLuaExprs(
361
+ extra: Array<{ key: string; jwt: string }>,
362
+ ): { auth: string; realtimeWs: string } {
363
+ // Keep a client's `Authorization: Bearer <extraKey>` from winning the
364
+ // header-passthrough clause with the raw (non-JWT) key.
365
+ const guards = extra
366
+ .map((m) => ` and headers.authorization ~= 'Bearer ${m.key}'`)
367
+ .join("");
368
+ const mappings = extra
369
+ .map(
370
+ (m) =>
371
+ ` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}') and 'Bearer ${m.jwt}')`,
372
+ )
373
+ .join("");
374
+ const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey)`;
375
+ const wsMappings = extra
376
+ .map((m) => `(query_params.apikey == '${m.key}' and '${m.jwt}') or `)
377
+ .join("");
378
+ return { auth, realtimeWs: `$(${wsMappings}query_params.apikey)` };
379
+ }
286
380
 
287
381
  const KONG_YML_RAW = String.raw`_format_version: '2.1'
288
382
  _transform: true
@@ -631,9 +725,26 @@ function buildKongYaml(
631
725
  serviceRoleKey: string;
632
726
  dashboardUsername: string;
633
727
  dashboardPassword: string;
728
+ extraAnonKeys: string[];
729
+ extraServiceRoleKeys: string[];
634
730
  },
635
731
  ): string {
636
732
  let y = KONG_YML_RAW;
733
+ // Extra API keys land where kong-entrypoint.sh's opaque keys would: as
734
+ // additional keyauth credentials on the matching consumer. The template's
735
+ // placeholder lines are either expanded here or dropped by the filter below.
736
+ if (vals.extraAnonKeys.length > 0) {
737
+ y = y.replace(
738
+ " - key: $SUPABASE_PUBLISHABLE_KEY",
739
+ vals.extraAnonKeys.map((k) => ` - key: ${k}`).join("\n"),
740
+ );
741
+ }
742
+ if (vals.extraServiceRoleKeys.length > 0) {
743
+ y = y.replace(
744
+ " - key: $SUPABASE_SECRET_KEY",
745
+ vals.extraServiceRoleKeys.map((k) => ` - key: ${k}`).join("\n"),
746
+ );
747
+ }
637
748
  // Rewrite upstream bare service hosts → the group's final DNS names.
638
749
  y = y
639
750
  .replaceAll("http://auth:9999", `http://${key("auth")}:9999`)
@@ -644,9 +755,13 @@ function buildKongYaml(
644
755
  .replaceAll("http://functions:9000", `http://${key("functions")}:9000`)
645
756
  .replaceAll("realtime-dev.supabase-realtime", `realtime-dev.${key("realtime")}`);
646
757
  // Lua expressions first (they contain no `$SUPABASE_*` tokens).
647
- y = y.replaceAll("$LUA_AUTH_EXPR", LUA_AUTH_EXPR).replaceAll(
758
+ const lua = buildLuaExprs([
759
+ ...vals.extraAnonKeys.map((k) => ({ key: k, jwt: vals.anonKey })),
760
+ ...vals.extraServiceRoleKeys.map((k) => ({ key: k, jwt: vals.serviceRoleKey })),
761
+ ]);
762
+ y = y.replaceAll("$LUA_AUTH_EXPR", lua.auth).replaceAll(
648
763
  "$LUA_RT_WS_EXPR",
649
- LUA_RT_WS_EXPR,
764
+ lua.realtimeWs,
650
765
  );
651
766
  // API keys + dashboard credentials.
652
767
  y = y
@@ -707,14 +822,17 @@ async function applyMigrations(
707
822
  ctx: ServiceSetupContext,
708
823
  cfg: { migrations: boolean | string; seed: boolean | string },
709
824
  ): Promise<void> {
710
- // Migrations directory.
825
+ // Migrations directory. An *explicitly configured* path that doesn't
826
+ // resolve is a hard error — a silent no-op here surfaces much later as a
827
+ // baffling `relation "public.…" does not exist`. Only the `true` default
828
+ // (conventional `supabase/migrations/`) is allowed to be absent.
711
829
  if (cfg.migrations !== false) {
712
- const dir =
713
- typeof cfg.migrations === "string"
714
- ? cfg.migrations.startsWith("/")
715
- ? cfg.migrations
716
- : join(ctx.projectRoot, cfg.migrations)
717
- : join(ctx.projectRoot, "supabase", "migrations");
830
+ const explicit = typeof cfg.migrations === "string";
831
+ const dir = explicit
832
+ ? (cfg.migrations as string).startsWith("/")
833
+ ? (cfg.migrations as string)
834
+ : join(ctx.projectRoot, cfg.migrations as string)
835
+ : join(ctx.projectRoot, "supabase", "migrations");
718
836
  if (existsSync(dir) && (await stat(dir)).isDirectory()) {
719
837
  const files = (await readdir(dir))
720
838
  .filter((f) => f.endsWith(".sql"))
@@ -723,19 +841,34 @@ async function applyMigrations(
723
841
  const sql = await ctx.readProjectFile(join(dir, f));
724
842
  await applySqlFile(ctx, sql, `migration ${f}`, "postgres");
725
843
  }
844
+ } else if (explicit) {
845
+ throw new Error(
846
+ `supabase: configured migrations path ${JSON.stringify(
847
+ cfg.migrations,
848
+ )} does not resolve to a directory (looked at ${dir}). ` +
849
+ `Fix the \`migrations\` option, or remove it to use the default ` +
850
+ `supabase/migrations/. Note the path is resolved inside the uploaded ` +
851
+ `project — check it isn't ignored (e.g. via .spectestignore).`,
852
+ );
726
853
  }
727
854
  }
728
- // Seed file.
855
+ // Seed file — same contract: only the conventional default may be absent.
729
856
  if (cfg.seed !== false) {
730
- const seedPath =
731
- typeof cfg.seed === "string"
732
- ? cfg.seed.startsWith("/")
733
- ? cfg.seed
734
- : join(ctx.projectRoot, cfg.seed)
735
- : join(ctx.projectRoot, "supabase", "seed.sql");
857
+ const explicit = typeof cfg.seed === "string";
858
+ const seedPath = explicit
859
+ ? (cfg.seed as string).startsWith("/")
860
+ ? (cfg.seed as string)
861
+ : join(ctx.projectRoot, cfg.seed as string)
862
+ : join(ctx.projectRoot, "supabase", "seed.sql");
736
863
  if (existsSync(seedPath)) {
737
864
  const sql = await ctx.readProjectFile(seedPath);
738
865
  await applySqlFile(ctx, sql, "seed.sql", "postgres");
866
+ } else if (explicit) {
867
+ throw new Error(
868
+ `supabase: configured seed path ${JSON.stringify(cfg.seed)} not found ` +
869
+ `(looked at ${seedPath}). Fix the \`seed\` option or remove it to ` +
870
+ `use the default supabase/seed.sql.`,
871
+ );
739
872
  }
740
873
  }
741
874
  }
@@ -773,6 +906,29 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
773
906
  const withStudio = opts.studio ?? false;
774
907
  const withMeta = opts.meta ?? withStudio;
775
908
 
909
+ // Mail capture (see SupabaseOptions.mail) — on unless explicitly disabled.
910
+ // `mailKey` is the SMTP host GoTrue mails through — the in-group part
911
+ // (`<name>-mail`) or a user-declared `email()` service shared with the app
912
+ // under test.
913
+ const withMail = opts.mail !== false;
914
+ const mailOpts: SupabaseMailOptions =
915
+ typeof opts.mail === "object" ? opts.mail : {};
916
+ const mailExternal = mailOpts.service !== undefined;
917
+ const mailKey = mailOpts.service ?? `${name}-mail`;
918
+ const mailAutoconfirm = mailOpts.autoconfirm ?? true;
919
+
920
+ const extraAnonKeys = opts.extraApiKeys?.anon ?? [];
921
+ const extraServiceRoleKeys = opts.extraApiKeys?.serviceRole ?? [];
922
+ for (const k of [...extraAnonKeys, ...extraServiceRoleKeys]) {
923
+ // The keys are spliced into Kong's YAML and its quoted Lua expressions —
924
+ // restrict to the token charset real API keys use so neither can break.
925
+ if (!/^[A-Za-z0-9._-]+$/.test(k)) {
926
+ throw new Error(
927
+ `supabase(): extraApiKeys entries must match [A-Za-z0-9._-]+ (got ${JSON.stringify(k)})`,
928
+ );
929
+ }
930
+ }
931
+
776
932
  const anonKey = apiKey("anon", jwtSecret);
777
933
  const serviceRoleKey = apiKey("service_role", jwtSecret);
778
934
 
@@ -872,6 +1028,11 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
872
1028
  readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
873
1029
  };
874
1030
 
1031
+ // ── mail (SMTP capture, the standard email() component) ─────────
1032
+ if (withMail && !mailExternal) {
1033
+ parts.mail = email();
1034
+ }
1035
+
875
1036
  // ── auth (GoTrue) ───────────────────────────────────────────────
876
1037
  if (withAuth) {
877
1038
  const apiExternalUrl = `${url}/auth/v1`;
@@ -894,14 +1055,39 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
894
1055
  GOTRUE_JWT_ISSUER: apiExternalUrl,
895
1056
  GOTRUE_EXTERNAL_EMAIL_ENABLED: "true",
896
1057
  GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true",
897
- // Autoconfirm so tests can sign up without a mail server
898
- // round-trip.
899
- GOTRUE_MAILER_AUTOCONFIRM: "true",
1058
+ // Autoconfirm defaults ON (one-step signups, no confirmation
1059
+ // mail) so the always-on mailbox is purely additive; the mail
1060
+ // option's `autoconfirm: false` opts into real confirmation
1061
+ // flows.
1062
+ GOTRUE_MAILER_AUTOCONFIRM:
1063
+ withMail && !mailAutoconfirm ? "false" : "true",
900
1064
  GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
901
1065
  GOTRUE_SMS_AUTOCONFIRM: "true",
1066
+ ...(withMail
1067
+ ? {
1068
+ GOTRUE_SMTP_HOST: mailKey,
1069
+ GOTRUE_SMTP_PORT: "1025",
1070
+ // Deliberately NO GOTRUE_SMTP_USER/PASS: the capture
1071
+ // server advertises AUTH, and with credentials set
1072
+ // GoTrue's Go smtp.PlainAuth refuses to send them over a
1073
+ // plaintext connection ("unencrypted connection" → every
1074
+ // mail 500s). Unauthenticated submission is accepted.
1075
+ GOTRUE_SMTP_ADMIN_EMAIL:
1076
+ mailOpts.adminEmail ?? "admin@example.com",
1077
+ GOTRUE_SMTP_SENDER_NAME: mailOpts.senderName ?? "Supabase",
1078
+ // GoTrue rate-limits repeat mail to the same address
1079
+ // (default 1/min) — far too slow for tests that
1080
+ // request an OTP, assert, and request again.
1081
+ GOTRUE_SMTP_MAX_FREQUENCY: "1s",
1082
+ GOTRUE_MAILER_URLPATHS_INVITE: "/auth/v1/verify",
1083
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: "/auth/v1/verify",
1084
+ GOTRUE_MAILER_URLPATHS_RECOVERY: "/auth/v1/verify",
1085
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify",
1086
+ }
1087
+ : {}),
902
1088
  },
903
1089
  ports: [9999],
904
- dependsOn: ["db"],
1090
+ dependsOn: ["db", ...(withMail && !mailExternal ? ["mail"] : [])],
905
1091
  readyCheck: { type: "http", port: 9999, path: "/health", timeoutSecs: 90 },
906
1092
  };
907
1093
  }
@@ -1071,6 +1257,8 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1071
1257
  serviceRoleKey,
1072
1258
  dashboardUsername,
1073
1259
  dashboardPassword,
1260
+ extraAnonKeys,
1261
+ extraServiceRoleKeys,
1074
1262
  }),
1075
1263
  },
1076
1264
  ],
@@ -1087,6 +1275,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1087
1275
  url,
1088
1276
  anonKey,
1089
1277
  serviceRoleKey,
1278
+ ...(withMail ? { mail: emailHelpers(mailKey) } : {}),
1090
1279
  }),
1091
1280
  });
1092
1281
 
@@ -1105,5 +1294,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1105
1294
  dbUrl,
1106
1295
  ready: name,
1107
1296
  appEnv,
1297
+ ...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
1108
1298
  };
1109
1299
  }
package/src/daemon.ts CHANGED
@@ -845,6 +845,19 @@ async function buildServiceImage(
845
845
  await fs.mkdir(dfDir, { recursive: true });
846
846
  const dfPath = path.join(dfDir, "Dockerfile");
847
847
  await fs.writeFile(dfPath, image.content);
848
+ // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
849
+ // (next to the Dockerfile) in preference to the context root's
850
+ // `.dockerignore`, so this build sees the defaults plus ITS OWN
851
+ // `exclude` only — one service excluding `handhelds/**` no longer
852
+ // empties a sibling's build context. Verified on both the remote-buildx
853
+ // and DOCKER_BUILDKIT paths (client-side context filtering). The root
854
+ // union `.dockerignore` written at bootstrap stays as the fallback for
855
+ // the legacy non-BuildKit builder, which predates per-Dockerfile
856
+ // ignores.
857
+ await fs.writeFile(
858
+ `${dfPath}.dockerignore`,
859
+ [...DEFAULT_DOCKERIGNORE, ...(image.exclude ?? [])].join("\n") + "\n",
860
+ );
848
861
  const useRemote = await ensureRemoteBuilder();
849
862
  // Both the remote builder and a local buildx are BuildKit, so both emit
850
863
  // per-step timing on stderr under `--progress=plain` (parsed below). Only
@@ -1478,6 +1491,88 @@ const HOP_BY_HOP_HEADERS = new Set([
1478
1491
  "host",
1479
1492
  ]);
1480
1493
 
1494
+ /**
1495
+ * Is this a CORS preflight? A preflight is the browser's own probe (never
1496
+ * app business logic): an `OPTIONS` carrying `Origin` +
1497
+ * `Access-Control-Request-Method`. Plain `OPTIONS` calls (no `ACRM`) are real
1498
+ * app requests and pass straight through to the upstream/fake.
1499
+ */
1500
+ function isCorsPreflight(req: Request): boolean {
1501
+ return (
1502
+ req.method === "OPTIONS" &&
1503
+ req.headers.has("origin") &&
1504
+ req.headers.has("access-control-request-method")
1505
+ );
1506
+ }
1507
+
1508
+ /**
1509
+ * Answer a CORS preflight at the ingress, permissively, reflecting exactly
1510
+ * what the browser asked for.
1511
+ *
1512
+ * Why this belongs in the platform, not the app: inside the hermetic sandbox
1513
+ * the app page's origin (e.g. `http://<svc>.internal:<port>`) and every host
1514
+ * it fetches through this ingress (`https://api.example.com`) are *always*
1515
+ * different origins, so any request with a non-safelisted header — which
1516
+ * includes `Authorization`, and crucially `Cache-Control` / `Pragma` — is
1517
+ * preflighted by the browser. If we forward the `OPTIONS` to the upstream, the
1518
+ * request succeeds or fails on whether *that* app happens to enumerate the
1519
+ * header in its `Access-Control-Allow-Headers`. Real apps list `Authorization`
1520
+ * but almost never `Cache-Control`/`Pragma`, so a client that sends those (many
1521
+ * HTTP libraries add `Cache-Control: no-cache` by default) fails the preflight
1522
+ * with an instant "Failed to fetch" — even though the identical request works
1523
+ * in production behind a permissive edge/gateway. Reflecting
1524
+ * `Access-Control-Request-Headers` verbatim makes the ingress transparent to
1525
+ * whatever header vocabulary the app under test uses.
1526
+ */
1527
+ function corsPreflightResponse(req: Request): Response {
1528
+ const origin = req.headers.get("origin") ?? "*";
1529
+ const reqHeaders = req.headers.get("access-control-request-headers");
1530
+ const reqMethod = req.headers.get("access-control-request-method");
1531
+ const headers = new Headers();
1532
+ headers.set("access-control-allow-origin", origin);
1533
+ // Echo the specific origin (not `*`) so credentialed requests are allowed;
1534
+ // `Allow-Origin: *` + `Allow-Credentials: true` is a spec violation browsers
1535
+ // reject.
1536
+ headers.set("access-control-allow-credentials", "true");
1537
+ headers.set(
1538
+ "access-control-allow-methods",
1539
+ reqMethod && reqMethod.length > 0
1540
+ ? reqMethod
1541
+ : "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
1542
+ );
1543
+ headers.set(
1544
+ "access-control-allow-headers",
1545
+ reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*",
1546
+ );
1547
+ headers.set("access-control-max-age", "600");
1548
+ // The response varies by the reflected origin/headers — keep caches honest.
1549
+ headers.append("vary", "Origin");
1550
+ headers.append("vary", "Access-Control-Request-Headers");
1551
+ return new Response(null, { status: 204, headers });
1552
+ }
1553
+
1554
+ /**
1555
+ * Make sure the browser sees an `Access-Control-Allow-Origin` it accepts on the
1556
+ * *actual* cross-origin response. Only fills one in when the upstream/fake
1557
+ * didn't set its own, so an app that manages CORS itself keeps full control;
1558
+ * this just stops a missing header from turning an otherwise-fine 200 into a
1559
+ * "Failed to fetch". No-op for same-origin requests (no `Origin`).
1560
+ */
1561
+ function augmentCorsResponse(req: Request, res: Response): Response {
1562
+ const origin = req.headers.get("origin");
1563
+ if (!origin) return res;
1564
+ if (res.headers.has("access-control-allow-origin")) return res;
1565
+ try {
1566
+ res.headers.set("access-control-allow-origin", origin);
1567
+ res.headers.set("access-control-allow-credentials", "true");
1568
+ res.headers.append("vary", "Origin");
1569
+ } catch {
1570
+ // Some responses (e.g. a 101 upgrade stub) carry guarded/immutable
1571
+ // headers — leave those untouched.
1572
+ }
1573
+ return res;
1574
+ }
1575
+
1481
1576
  /**
1482
1577
  * Bring ingress servers up: bind one Bun.serve per unique HTTP port
1483
1578
  * (fakes' ports plus the always-on :80 for service proxies), plus a
@@ -1865,9 +1960,15 @@ async function dispatchIngress(
1865
1960
  { status: 404, headers: { "content-type": "text/plain" } },
1866
1961
  );
1867
1962
  }
1963
+ // Answer CORS preflights at the ingress (see corsPreflightResponse) so a
1964
+ // cross-origin browser request carrying any header — Authorization,
1965
+ // Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
1966
+ // to list in Access-Control-Allow-Headers.
1967
+ if (isCorsPreflight(req)) return corsPreflightResponse(req);
1868
1968
  if (route.kind === "fake") {
1869
1969
  try {
1870
- return await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1970
+ const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1971
+ return augmentCorsResponse(req, res);
1871
1972
  } catch (err) {
1872
1973
  const e = err as Error;
1873
1974
  return new Response(
@@ -1876,7 +1977,15 @@ async function dispatchIngress(
1876
1977
  );
1877
1978
  }
1878
1979
  }
1879
- return proxyToService(req, server, route.service, route.port, listenerLabel, proto);
1980
+ const res = await proxyToService(
1981
+ req,
1982
+ server,
1983
+ route.service,
1984
+ route.port,
1985
+ listenerLabel,
1986
+ proto,
1987
+ );
1988
+ return augmentCorsResponse(req, res);
1880
1989
  }
1881
1990
 
1882
1991
  /**
@@ -1949,6 +2058,16 @@ async function proxyToService(
1949
2058
  // the public hostname. Lets origin servers that vhost by Host header
1950
2059
  // continue to find the right virtual host.
1951
2060
  fwdHeaders.set("host", `${service}:${port}`);
2061
+ // Fresh connection per upstream request — never reuse a pooled
2062
+ // keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
2063
+ // to 5s) close pooled connections under Bun's fetch, and the next
2064
+ // request on the dead socket fails with "socket closed unexpectedly"
2065
+ // even though the service is healthy. In-VM connects to a peer
2066
+ // container are sub-ms, so per-request connects cost nothing at test
2067
+ // scale. `connection: close` makes the upstream tear down immediately;
2068
+ // `keepalive: false` on the fetch below keeps Bun from pooling its end.
2069
+ // (Both verified effective on Bun 1.3.14.)
2070
+ fwdHeaders.set("connection", "close");
1952
2071
 
1953
2072
  // Buffer bounded request bodies so a transient upstream connect failure
1954
2073
  // can be retried (a ReadableStream body is consumed by the first
@@ -1989,8 +2108,13 @@ async function proxyToService(
1989
2108
  redirect: "manual",
1990
2109
  });
1991
2110
  // decompress:false → forward the encoded body untouched (see fn doc).
2111
+ // keepalive:false → fresh connection per request (see fwdHeaders above).
1992
2112
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1993
- const upstreamRes = await NATIVE_FETCH(upstreamReq, { decompress: false } as any);
2113
+ const upstreamRes = await NATIVE_FETCH(upstreamReq, {
2114
+ decompress: false,
2115
+ keepalive: false,
2116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2117
+ } as any);
1994
2118
  // Strip hop-by-hop response headers; let Bun set content-length / TE.
1995
2119
  const respHeaders = new Headers();
1996
2120
  for (const [k, v] of upstreamRes.headers) {
@@ -2004,11 +2128,18 @@ async function proxyToService(
2004
2128
  });
2005
2129
  } catch (err) {
2006
2130
  lastErr = err;
2007
- // Only connect-class failures are safely retryable — if the request
2008
- // reached the upstream we must not replay it.
2131
+ // Retry connection-level failures: connect errors, plus a socket
2132
+ // that died before any response bytes ("socket closed unexpectedly",
2133
+ // ECONNRESET, hang-up) — under boot-time bursts an upstream accepts
2134
+ // and drops connections while still warming up. Pre-response
2135
+ // failures are the standard retry class for reverse proxies
2136
+ // (nginx's proxy_next_upstream error). Anything that produced a
2137
+ // response is never replayed.
2009
2138
  const msg = (err as Error)?.message ?? String(err);
2010
2139
  const connectFailure =
2011
- /unable to connect|connection refused|connect|typo in the url/i.test(msg);
2140
+ /unable to connect|connection refused|connect|typo in the url|socket closed|connection closed|econnreset|socket hang ?up|epipe/i.test(
2141
+ msg,
2142
+ );
2012
2143
  if (!connectFailure || attempt === attempts) break;
2013
2144
  // The cached IP may be stale (container recreated) — re-resolve.
2014
2145
  PROXY_IP_CACHE.delete(service);
package/src/mobile.ts CHANGED
@@ -18,6 +18,7 @@ import { acquirePersistentMobileBackend, openMobileBackend } from "./browser.js"
18
18
  import type {
19
19
  BrowserSessionRecorder,
20
20
  MobileBackend,
21
+ SafeAreaInsets,
21
22
  ScreenshotOptions,
22
23
  } from "./browser.js";
23
24
  import type { Wrapped } from "./inspect.js";
@@ -68,12 +69,13 @@ interface LocatorDesc {
68
69
  * moment an action runs. Mirrors the select-then-act idiom shared by
69
70
  * Playwright, Detox, and RN Testing Library. */
70
71
  export interface MobileLocator {
71
- /** Wait for the element to be visible, then touch-tap its center. */
72
- tap(): Promise<void>;
72
+ /** Wait for the element to be visible (default 5s, `timeoutMs` overrides),
73
+ * then touch-tap its center. `durationMs` overrides the touch dwell. */
74
+ tap(opts?: { timeoutMs?: number; durationMs?: number }): Promise<void>;
73
75
  /** Tap to focus, then type `text` via real key events. */
74
- typeText(text: string): Promise<void>;
76
+ typeText(text: string, opts?: { timeoutMs?: number }): Promise<void>;
75
77
  /** Clear a text input's current value (RN-Web controlled input safe). */
76
- clearText(): Promise<void>;
78
+ clearText(opts?: { timeoutMs?: number }): Promise<void>;
77
79
  /** Scroll the element to the center of the viewport. */
78
80
  scrollIntoView(): Promise<void>;
79
81
  /** Wait until the element is attached and visible (throws on timeout). */
@@ -167,6 +169,11 @@ function descLabel(desc: LocatorDesc): string {
167
169
  return `${desc.kind} ${JSON.stringify(desc.value)}`;
168
170
  }
169
171
 
172
+ /** Default wait for a locator action's target to become visible. Override
173
+ * per action with `{ timeoutMs }` (animations that move elements mid-flight
174
+ * often need more than the default). */
175
+ const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
176
+
170
177
  function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
171
178
  const label = descLabel(desc);
172
179
 
@@ -187,17 +194,17 @@ function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
187
194
  }
188
195
 
189
196
  return {
190
- async tap() {
191
- const { x, y } = await waitCoords(5_000);
192
- await backend.tapAt(x, y);
197
+ async tap(opts) {
198
+ const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
199
+ await backend.tapAt(x, y, { durationMs: opts?.durationMs });
193
200
  },
194
- async typeText(text) {
195
- const { x, y } = await waitCoords(5_000);
201
+ async typeText(text, opts) {
202
+ const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
196
203
  await backend.tapAt(x, y);
197
204
  await backend.type(text);
198
205
  },
199
- async clearText() {
200
- await waitCoords(5_000);
206
+ async clearText(opts) {
207
+ await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
201
208
  // Native value-setter + input event so RN-Web's controlled TextInput
202
209
  // sees the change (a plain `.value = ""` is swallowed by React).
203
210
  await backend.evaluate(
@@ -268,6 +275,13 @@ export interface Mobile {
268
275
  getByLabel(text: string): MobileLocator;
269
276
  /** Escape hatch: select by a raw CSS selector. */
270
277
  locator(css: string): MobileLocator;
278
+ /**
279
+ * Touch-tap at viewport CSS coordinates — the escape hatch for targets no
280
+ * locator can select (unlabeled icon-only buttons, canvas hit areas). Same
281
+ * real-touch dispatch and dwell as a locator `tap()`. Prefer locators when
282
+ * the element has a testID/label; coordinates break on layout changes.
283
+ */
284
+ tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
271
285
  /** Swipe the screen in a direction (a touch drag from the center). */
272
286
  swipe(direction: "up" | "down" | "left" | "right", opts?: { distance?: number }): Promise<void>;
273
287
  /** Wheel-scroll the viewport by a pixel delta. */
@@ -278,6 +292,14 @@ export interface Mobile {
278
292
  back(): Promise<void>;
279
293
  /** Low-level escape hatch: evaluate JS in the page (recorded, wrapped). */
280
294
  evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
295
+ /**
296
+ * Install a script that runs before every *subsequent* document's own
297
+ * scripts (the Playwright `addInitScript` equivalent) — the deterministic
298
+ * way to plant shims/instrumentation that must win the race against the
299
+ * app bundle. Takes effect on the next navigation (e.g. a
300
+ * `location.assign` deep link), not the current document.
301
+ */
302
+ addInitScript(description: string, source: string): Promise<void>;
281
303
  /** Low-level escape hatch: poll a JS expression until truthy (recorded). */
282
304
  waitFor<T = unknown>(
283
305
  description: string,
@@ -325,6 +347,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
325
347
  locator(css) {
326
348
  return makeLocator(backend, { kind: "css", value: css });
327
349
  },
350
+ tapAt(x, y, opts) {
351
+ return backend.tapAt(x, y, opts);
352
+ },
328
353
  async swipe(direction, opts) {
329
354
  const vp = await backend.probe<{ w: number; h: number }>(
330
355
  "({ w: window.innerWidth, h: window.innerHeight })",
@@ -349,6 +374,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
349
374
  evaluate(description, script) {
350
375
  return backend.evaluate(description, script);
351
376
  },
377
+ addInitScript(description, source) {
378
+ return backend.addInitScript(description, source);
379
+ },
352
380
  waitFor(description, expression, options) {
353
381
  return backend.waitFor(description, expression, options);
354
382
  },
@@ -389,10 +417,22 @@ export async function openMobile(opts: {
389
417
  export async function openPersistentMobile(opts: {
390
418
  url: string;
391
419
  recorder: BrowserSessionRecorder | null;
392
- }): Promise<{ mobile: Mobile; attached: boolean; detach(): Promise<void> }> {
420
+ }): Promise<{
421
+ mobile: Mobile;
422
+ attached: boolean;
423
+ detach(): Promise<void>;
424
+ /** Safe-area insets emulated on the view (`null` when the CDP override
425
+ * is unavailable) — the daemon stamps them onto the session record. */
426
+ safeAreaInsets: SafeAreaInsets | null;
427
+ }> {
393
428
  const { browser, attached, detach } = await acquirePersistentMobileBackend(
394
429
  opts.url,
395
430
  opts.recorder,
396
431
  );
397
- return { mobile: wrapMobile(browser), attached, detach };
432
+ return {
433
+ mobile: wrapMobile(browser),
434
+ attached,
435
+ detach,
436
+ safeAreaInsets: browser.safeAreaInsets,
437
+ };
398
438
  }
package/src/recorder.ts CHANGED
@@ -115,8 +115,12 @@ export interface DbEvent extends BaseEvent {
115
115
  query: string;
116
116
  /** Parameter values. Best-effort JSON-safe; large/binary values stringified. */
117
117
  params?: unknown[];
118
- /** Rows returned, when known. */
118
+ /** Rows returned — or rows AFFECTED when `rowsAffected` is set. */
119
119
  rowCount?: number;
120
+ /** Set when `rowCount` counts affected rows (a non-RETURNING
121
+ * INSERT/UPDATE/DELETE) rather than a result set — rendered as
122
+ * "N affected" so an `UPDATE → 0 rows` can't read as "nothing updated". */
123
+ rowsAffected?: boolean;
120
124
  /** Captured rows for table rendering in the web UI. Capped at
121
125
  * MAX_DB_ROWS; `rowsTruncated` is set when there were more. */
122
126
  rows?: unknown[];
@@ -367,6 +371,7 @@ export interface EnvEvent extends BaseEvent {
367
371
  export type BrowserAction =
368
372
  | "navigate"
369
373
  | "evaluate"
374
+ | "addInitScript"
370
375
  | "waitFor"
371
376
  | "click"
372
377
  | "type"
package/src/sql.ts CHANGED
@@ -124,6 +124,7 @@ export function instrumentSql(raw: RawSqlClient, label: string): SqlClient {
124
124
  query,
125
125
  params: params.length > 0 ? params.map(safeSerialize) : undefined,
126
126
  rowCount: rowCountOf(value),
127
+ rowsAffected: isAffectedCount(query, value) || undefined,
127
128
  rows,
128
129
  rowsTruncated,
129
130
  columns,
@@ -192,15 +193,34 @@ function reconstructSqlTemplate(
192
193
  }
193
194
 
194
195
  function rowCountOf(value: unknown): number | undefined {
195
- if (Array.isArray(value)) return value.length;
196
196
  if (value && typeof value === "object") {
197
197
  const obj = value as { count?: unknown; rowCount?: unknown };
198
+ // Bun.SQL (postgres.js semantics): `.count` is rows RETURNED for
199
+ // SELECT/RETURNING queries and rows AFFECTED for other writes — where
200
+ // the result array itself is empty. Check it before `.length`, or a
201
+ // non-RETURNING `UPDATE` that touched 3 rows records a misleading 0.
198
202
  if (typeof obj.count === "number") return obj.count;
199
203
  if (typeof obj.rowCount === "number") return obj.rowCount;
200
204
  }
205
+ if (Array.isArray(value)) return value.length;
201
206
  return undefined;
202
207
  }
203
208
 
209
+ /** True when the recorded count means "rows affected" rather than "rows
210
+ * returned" — a write with no result set. Lets the dashboard render
211
+ * `3 affected` instead of `3 rows` (and keeps `UPDATE … (0 rows)` from
212
+ * reading as "nothing updated"). */
213
+ function isAffectedCount(query: string, value: unknown): boolean {
214
+ if (Array.isArray(value) && value.length > 0) return false; // rows came back
215
+ const q = query.trimStart().slice(0, 8).toUpperCase();
216
+ return (
217
+ q.startsWith("INSERT") ||
218
+ q.startsWith("UPDATE") ||
219
+ q.startsWith("DELETE") ||
220
+ q.startsWith("MERGE")
221
+ );
222
+ }
223
+
204
224
  const MAX_DB_ROWS = 50;
205
225
 
206
226
  function captureRows(value: unknown): {