@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 +1 -1
- package/src/browser.ts +148 -13
- package/src/components/email.ts +11 -0
- package/src/components/expo.ts +24 -2
- package/src/components/supabase.ts +216 -26
- package/src/daemon.ts +137 -6
- package/src/mobile.ts +53 -13
- package/src/recorder.ts +6 -1
- package/src/sql.ts +21 -1
package/package.json
CHANGED
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
|
|
163
|
-
*
|
|
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
|
-
/**
|
|
237
|
-
|
|
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<
|
|
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 = {
|
|
799
|
-
|
|
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)
|
|
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>(
|
|
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: [],
|
package/src/components/email.ts
CHANGED
|
@@ -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() {
|
package/src/components/expo.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
281
|
-
//
|
|
282
|
-
// Authorization header.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
|
898
|
-
//
|
|
899
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
//
|
|
2008
|
-
//
|
|
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(
|
|
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
|
|
72
|
-
tap
|
|
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(
|
|
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(
|
|
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(
|
|
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<{
|
|
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 {
|
|
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
|
|
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): {
|