@specific.dev/spectest 0.15.0 → 0.16.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.16.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
@@ -167,6 +167,24 @@ export interface Browser {
167
167
  * test event log so the step list isn't a wall of minified code.
168
168
  */
169
169
  evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
170
+ /**
171
+ * Install a script that runs in every document loaded from now on, BEFORE
172
+ * any of the document's own scripts execute (CDP
173
+ * `Page.addScriptToEvaluateOnNewDocument` — the Playwright `addInitScript`
174
+ * equivalent). The deterministic way to plant shims and instrumentation
175
+ * (`fetch`/`XMLHttpRequest` wrappers, clock stubs, feature flags): unlike an
176
+ * `evaluate` racing the app bundle after a navigation, an init script is
177
+ * guaranteed to win.
178
+ *
179
+ * Does NOT run in the *current* document — call it before the `navigate`
180
+ * (or in-page `location.assign`) whose document needs it. Installed
181
+ * scripts persist for the browser session's lifetime, which for the
182
+ * persistent `ctx.browser()`/`ctx.mobile()` sessions means they ride
183
+ * snapshots into `dependsOn` children like the rest of the session state.
184
+ *
185
+ * `description` labels the step in the test event log.
186
+ */
187
+ addInitScript(description: string, source: string): Promise<void>;
170
188
  /**
171
189
  * Poll `expression` in the page until it returns a truthy value
172
190
  * (the returned value is what `waitFor` resolves with). Useful for
@@ -233,6 +251,10 @@ export interface Browser {
233
251
  * drain as the desktop verbs.
234
252
  */
235
253
  export interface MobileBackend extends Browser {
254
+ /** Safe-area insets emulated on this view (`null` on desktop views or
255
+ * when the CDP override is unavailable). The daemon stamps these onto
256
+ * the session record for the dashboard's replay. */
257
+ readonly safeAreaInsets: SafeAreaInsets | null;
236
258
  /** Touch-tap at viewport CSS coordinates (touchStart→touchEnd). */
237
259
  tapAt(x: number, y: number): Promise<void>;
238
260
  /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
@@ -281,6 +303,15 @@ interface DevicePreset {
281
303
  isMobile: boolean;
282
304
  hasTouch: boolean;
283
305
  userAgent: string;
306
+ safeAreaInsets: SafeAreaInsets;
307
+ }
308
+
309
+ /** iOS safe-area insets (CSS `env(safe-area-inset-*)`), in CSS px. */
310
+ export interface SafeAreaInsets {
311
+ top: number;
312
+ right: number;
313
+ bottom: number;
314
+ left: number;
284
315
  }
285
316
 
286
317
  /** The fixed mobile device. Logical resolution + DPR of a current iPhone;
@@ -296,16 +327,28 @@ const LATEST_IPHONE: DevicePreset = {
296
327
  userAgent:
297
328
  "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
298
329
  "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
330
+ // Portrait safe area of the 393×852 iPhones (14 Pro through 16): 59pt
331
+ // status-bar/Dynamic-Island clearance on top, 34pt home-indicator strip
332
+ // at the bottom. Emulated via CDP so the app's `env(safe-area-inset-*)`
333
+ // padding fires exactly like on the real device.
334
+ safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
299
335
  };
300
336
 
301
337
  /** Apply CDP device emulation to a freshly-created view. The overrides are
302
338
  * CDP-session-global, so they persist across the app navigation that
303
339
  * 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. */
340
+ * a CDP failure degrades to a plain desktop view rather than aborting.
341
+ *
342
+ * Returns the safe-area insets that actually took effect (`null` when the
343
+ * override failed — Chromium < 135 lacks the CDP method). The caller
344
+ * stamps them onto the session record so the dashboard can substitute the
345
+ * same values for `env(safe-area-inset-*)` in the replayed CSS; stamping
346
+ * only what was really applied keeps capture layout and replay layout in
347
+ * lockstep (recorded touch coordinates would misalign otherwise). */
305
348
  async function applyDeviceEmulation(
306
349
  view: BunWebViewInstance,
307
350
  d: DevicePreset,
308
- ): Promise<void> {
351
+ ): Promise<SafeAreaInsets | null> {
309
352
  try {
310
353
  await view.cdp("Emulation.setDeviceMetricsOverride", {
311
354
  width: d.viewport.width,
@@ -323,6 +366,17 @@ async function applyDeviceEmulation(
323
366
  } catch (err) {
324
367
  // eslint-disable-next-line no-console
325
368
  console.warn("[spectest] device emulation failed; using desktop view:", err);
369
+ return null;
370
+ }
371
+ try {
372
+ await view.cdp("Emulation.setSafeAreaInsetsOverride", {
373
+ insets: { ...d.safeAreaInsets },
374
+ });
375
+ return d.safeAreaInsets;
376
+ } catch (err) {
377
+ // eslint-disable-next-line no-console
378
+ console.warn("[spectest] safe-area inset emulation unavailable:", err);
379
+ return null;
326
380
  }
327
381
  }
328
382
 
@@ -632,6 +686,13 @@ interface ViewHolder {
632
686
  device: DevicePreset | null;
633
687
  width: number;
634
688
  height: number;
689
+ /** Safe-area insets actually applied to the view (`null` for desktop
690
+ * views or when the CDP override is unavailable). Stamped onto the
691
+ * session record so the replay can mirror them. */
692
+ safeAreaInsets: SafeAreaInsets | null;
693
+ /** User scripts installed via `addInitScript`, kept so the DNS-recovery
694
+ * rebuild can re-install them on the replacement view. */
695
+ initScripts: string[];
635
696
  }
636
697
 
637
698
  // Pre-opened view pool. Renderer spawn is the expensive part of
@@ -725,9 +786,11 @@ export async function openMobileBackend(
725
786
  device,
726
787
  width: wantW,
727
788
  height: wantH,
789
+ safeAreaInsets: null,
790
+ initScripts: [],
728
791
  };
729
792
 
730
- if (device) await applyDeviceEmulation(holder.view, device);
793
+ if (device) holder.safeAreaInsets = await applyDeviceEmulation(holder.view, device);
731
794
 
732
795
  const { backend } = buildBackend(holder, opts.recorder ?? null, {
733
796
  persistent: false,
@@ -795,8 +858,16 @@ async function newHolder(
795
858
  device: DevicePreset | null,
796
859
  ): Promise<ViewHolder> {
797
860
  const { view, recordingInstalled } = await createView(width, height);
798
- const holder: ViewHolder = { view, recordingInstalled, device, width, height };
799
- if (device) await applyDeviceEmulation(view, device);
861
+ const holder: ViewHolder = {
862
+ view,
863
+ recordingInstalled,
864
+ device,
865
+ width,
866
+ height,
867
+ safeAreaInsets: null,
868
+ initScripts: [],
869
+ };
870
+ if (device) holder.safeAreaInsets = await applyDeviceEmulation(view, device);
800
871
  return holder;
801
872
  }
802
873
 
@@ -916,7 +987,12 @@ async function rebuildView(holder: ViewHolder): Promise<void> {
916
987
  const fresh = await createView(holder.width, holder.height);
917
988
  holder.view = fresh.view;
918
989
  holder.recordingInstalled = fresh.recordingInstalled;
919
- if (holder.device) await applyDeviceEmulation(holder.view, holder.device);
990
+ if (holder.device) {
991
+ holder.safeAreaInsets = await applyDeviceEmulation(holder.view, holder.device);
992
+ }
993
+ for (const source of holder.initScripts) {
994
+ await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", { source });
995
+ }
920
996
  }
921
997
 
922
998
  interface BackendBuildOptions {
@@ -1032,6 +1108,9 @@ function buildBackend(
1032
1108
  get title() {
1033
1109
  return holder.view.title;
1034
1110
  },
1111
+ get safeAreaInsets() {
1112
+ return holder.safeAreaInsets;
1113
+ },
1035
1114
  navigate(url) {
1036
1115
  recorder?.noteNavigation?.(url);
1037
1116
  return instrumented("navigate", { url }, async () => {
@@ -1070,6 +1149,24 @@ function buildBackend(
1070
1149
  },
1071
1150
  ) as Promise<Wrapped<T>>;
1072
1151
  },
1152
+ addInitScript(description: string, source: string): Promise<void> {
1153
+ const truncated = truncateUtf8(source);
1154
+ return instrumented(
1155
+ "addInitScript",
1156
+ {
1157
+ description,
1158
+ script: truncated.value,
1159
+ scriptTruncated: truncated.truncated,
1160
+ },
1161
+ async () => {
1162
+ await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", {
1163
+ source,
1164
+ });
1165
+ // Remember it so a DNS-recovery view rebuild re-installs it.
1166
+ holder.initScripts.push(source);
1167
+ },
1168
+ );
1169
+ },
1073
1170
  async waitFor<T = unknown>(
1074
1171
  description: string,
1075
1172
  expression: string,
@@ -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() {
@@ -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
@@ -1478,6 +1478,88 @@ const HOP_BY_HOP_HEADERS = new Set([
1478
1478
  "host",
1479
1479
  ]);
1480
1480
 
1481
+ /**
1482
+ * Is this a CORS preflight? A preflight is the browser's own probe (never
1483
+ * app business logic): an `OPTIONS` carrying `Origin` +
1484
+ * `Access-Control-Request-Method`. Plain `OPTIONS` calls (no `ACRM`) are real
1485
+ * app requests and pass straight through to the upstream/fake.
1486
+ */
1487
+ function isCorsPreflight(req: Request): boolean {
1488
+ return (
1489
+ req.method === "OPTIONS" &&
1490
+ req.headers.has("origin") &&
1491
+ req.headers.has("access-control-request-method")
1492
+ );
1493
+ }
1494
+
1495
+ /**
1496
+ * Answer a CORS preflight at the ingress, permissively, reflecting exactly
1497
+ * what the browser asked for.
1498
+ *
1499
+ * Why this belongs in the platform, not the app: inside the hermetic sandbox
1500
+ * the app page's origin (e.g. `http://<svc>.internal:<port>`) and every host
1501
+ * it fetches through this ingress (`https://api.example.com`) are *always*
1502
+ * different origins, so any request with a non-safelisted header — which
1503
+ * includes `Authorization`, and crucially `Cache-Control` / `Pragma` — is
1504
+ * preflighted by the browser. If we forward the `OPTIONS` to the upstream, the
1505
+ * request succeeds or fails on whether *that* app happens to enumerate the
1506
+ * header in its `Access-Control-Allow-Headers`. Real apps list `Authorization`
1507
+ * but almost never `Cache-Control`/`Pragma`, so a client that sends those (many
1508
+ * HTTP libraries add `Cache-Control: no-cache` by default) fails the preflight
1509
+ * with an instant "Failed to fetch" — even though the identical request works
1510
+ * in production behind a permissive edge/gateway. Reflecting
1511
+ * `Access-Control-Request-Headers` verbatim makes the ingress transparent to
1512
+ * whatever header vocabulary the app under test uses.
1513
+ */
1514
+ function corsPreflightResponse(req: Request): Response {
1515
+ const origin = req.headers.get("origin") ?? "*";
1516
+ const reqHeaders = req.headers.get("access-control-request-headers");
1517
+ const reqMethod = req.headers.get("access-control-request-method");
1518
+ const headers = new Headers();
1519
+ headers.set("access-control-allow-origin", origin);
1520
+ // Echo the specific origin (not `*`) so credentialed requests are allowed;
1521
+ // `Allow-Origin: *` + `Allow-Credentials: true` is a spec violation browsers
1522
+ // reject.
1523
+ headers.set("access-control-allow-credentials", "true");
1524
+ headers.set(
1525
+ "access-control-allow-methods",
1526
+ reqMethod && reqMethod.length > 0
1527
+ ? reqMethod
1528
+ : "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
1529
+ );
1530
+ headers.set(
1531
+ "access-control-allow-headers",
1532
+ reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*",
1533
+ );
1534
+ headers.set("access-control-max-age", "600");
1535
+ // The response varies by the reflected origin/headers — keep caches honest.
1536
+ headers.append("vary", "Origin");
1537
+ headers.append("vary", "Access-Control-Request-Headers");
1538
+ return new Response(null, { status: 204, headers });
1539
+ }
1540
+
1541
+ /**
1542
+ * Make sure the browser sees an `Access-Control-Allow-Origin` it accepts on the
1543
+ * *actual* cross-origin response. Only fills one in when the upstream/fake
1544
+ * didn't set its own, so an app that manages CORS itself keeps full control;
1545
+ * this just stops a missing header from turning an otherwise-fine 200 into a
1546
+ * "Failed to fetch". No-op for same-origin requests (no `Origin`).
1547
+ */
1548
+ function augmentCorsResponse(req: Request, res: Response): Response {
1549
+ const origin = req.headers.get("origin");
1550
+ if (!origin) return res;
1551
+ if (res.headers.has("access-control-allow-origin")) return res;
1552
+ try {
1553
+ res.headers.set("access-control-allow-origin", origin);
1554
+ res.headers.set("access-control-allow-credentials", "true");
1555
+ res.headers.append("vary", "Origin");
1556
+ } catch {
1557
+ // Some responses (e.g. a 101 upgrade stub) carry guarded/immutable
1558
+ // headers — leave those untouched.
1559
+ }
1560
+ return res;
1561
+ }
1562
+
1481
1563
  /**
1482
1564
  * Bring ingress servers up: bind one Bun.serve per unique HTTP port
1483
1565
  * (fakes' ports plus the always-on :80 for service proxies), plus a
@@ -1865,9 +1947,15 @@ async function dispatchIngress(
1865
1947
  { status: 404, headers: { "content-type": "text/plain" } },
1866
1948
  );
1867
1949
  }
1950
+ // Answer CORS preflights at the ingress (see corsPreflightResponse) so a
1951
+ // cross-origin browser request carrying any header — Authorization,
1952
+ // Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
1953
+ // to list in Access-Control-Allow-Headers.
1954
+ if (isCorsPreflight(req)) return corsPreflightResponse(req);
1868
1955
  if (route.kind === "fake") {
1869
1956
  try {
1870
- return await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1957
+ const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1958
+ return augmentCorsResponse(req, res);
1871
1959
  } catch (err) {
1872
1960
  const e = err as Error;
1873
1961
  return new Response(
@@ -1876,7 +1964,15 @@ async function dispatchIngress(
1876
1964
  );
1877
1965
  }
1878
1966
  }
1879
- return proxyToService(req, server, route.service, route.port, listenerLabel, proto);
1967
+ const res = await proxyToService(
1968
+ req,
1969
+ server,
1970
+ route.service,
1971
+ route.port,
1972
+ listenerLabel,
1973
+ proto,
1974
+ );
1975
+ return augmentCorsResponse(req, res);
1880
1976
  }
1881
1977
 
1882
1978
  /**
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";
@@ -278,6 +279,14 @@ export interface Mobile {
278
279
  back(): Promise<void>;
279
280
  /** Low-level escape hatch: evaluate JS in the page (recorded, wrapped). */
280
281
  evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
282
+ /**
283
+ * Install a script that runs before every *subsequent* document's own
284
+ * scripts (the Playwright `addInitScript` equivalent) — the deterministic
285
+ * way to plant shims/instrumentation that must win the race against the
286
+ * app bundle. Takes effect on the next navigation (e.g. a
287
+ * `location.assign` deep link), not the current document.
288
+ */
289
+ addInitScript(description: string, source: string): Promise<void>;
281
290
  /** Low-level escape hatch: poll a JS expression until truthy (recorded). */
282
291
  waitFor<T = unknown>(
283
292
  description: string,
@@ -349,6 +358,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
349
358
  evaluate(description, script) {
350
359
  return backend.evaluate(description, script);
351
360
  },
361
+ addInitScript(description, source) {
362
+ return backend.addInitScript(description, source);
363
+ },
352
364
  waitFor(description, expression, options) {
353
365
  return backend.waitFor(description, expression, options);
354
366
  },
@@ -389,10 +401,22 @@ export async function openMobile(opts: {
389
401
  export async function openPersistentMobile(opts: {
390
402
  url: string;
391
403
  recorder: BrowserSessionRecorder | null;
392
- }): Promise<{ mobile: Mobile; attached: boolean; detach(): Promise<void> }> {
404
+ }): Promise<{
405
+ mobile: Mobile;
406
+ attached: boolean;
407
+ detach(): Promise<void>;
408
+ /** Safe-area insets emulated on the view (`null` when the CDP override
409
+ * is unavailable) — the daemon stamps them onto the session record. */
410
+ safeAreaInsets: SafeAreaInsets | null;
411
+ }> {
393
412
  const { browser, attached, detach } = await acquirePersistentMobileBackend(
394
413
  opts.url,
395
414
  opts.recorder,
396
415
  );
397
- return { mobile: wrapMobile(browser), attached, detach };
416
+ return {
417
+ mobile: wrapMobile(browser),
418
+ attached,
419
+ detach,
420
+ safeAreaInsets: browser.safeAreaInsets,
421
+ };
398
422
  }
package/src/recorder.ts CHANGED
@@ -367,6 +367,7 @@ export interface EnvEvent extends BaseEvent {
367
367
  export type BrowserAction =
368
368
  | "navigate"
369
369
  | "evaluate"
370
+ | "addInitScript"
370
371
  | "waitFor"
371
372
  | "click"
372
373
  | "type"