@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 +1 -1
- package/src/browser.ts +103 -6
- package/src/components/email.ts +11 -0
- package/src/components/supabase.ts +216 -26
- package/src/daemon.ts +98 -2
- package/src/mobile.ts +26 -2
- package/src/recorder.ts +1 -0
package/package.json
CHANGED
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<
|
|
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 = {
|
|
799
|
-
|
|
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)
|
|
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,
|
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() {
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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<{
|
|
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 {
|
|
416
|
+
return {
|
|
417
|
+
mobile: wrapMobile(browser),
|
|
418
|
+
attached,
|
|
419
|
+
detach,
|
|
420
|
+
safeAreaInsets: browser.safeAreaInsets,
|
|
421
|
+
};
|
|
398
422
|
}
|