@specific.dev/spectest 0.13.0 → 0.15.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/src/index.ts CHANGED
@@ -205,6 +205,69 @@ export interface ServiceConfig {
205
205
  cgroupns?: string;
206
206
  }
207
207
 
208
+ /**
209
+ * The slice of the in-VM world a component's `setup` / `helpers` hooks can
210
+ * reach — beyond what the service definition itself declares. Handed to
211
+ * both hooks (see {@link ServiceSetupContext} / {@link ServiceHelpersContext})
212
+ * so components stop hand-rolling `child_process` docker execs and stop
213
+ * hard-coding control-plane paths like `/workspace`.
214
+ */
215
+ export interface ComponentContext {
216
+ /**
217
+ * Absolute path of the extracted project root inside the VM — the
218
+ * directory the user's repo lands in, with `spectest/` directly under
219
+ * it. Use this (never a hard-coded path) to locate project files a
220
+ * component consumes, e.g. `supabase/migrations/**`.
221
+ */
222
+ projectRoot: string;
223
+ /** Read a project file as UTF-8. Relative paths resolve against
224
+ * {@link projectRoot}; absolute paths are read as-is. */
225
+ readProjectFile(path: string): Promise<string>;
226
+ /**
227
+ * Run a command inside a service container (a raw `docker exec` — not
228
+ * recorded on any test timeline, result is plain, not
229
+ * provenance-wrapped). Pass an **array** for exact argv with no shell
230
+ * (`["psql", "-f", "-"]`), or a **string** to run via `sh -lc`.
231
+ * `opts.stdin` is piped to the process — the natural way to feed a SQL
232
+ * file to `psql -f -` or a manifest to `kubectl apply -f -`.
233
+ *
234
+ * Never throws on non-zero exit — inspect `exitCode` yourself.
235
+ */
236
+ exec(
237
+ service: string,
238
+ command: string | string[],
239
+ opts?: ComponentExecOpts,
240
+ ): Promise<ExecResult>;
241
+ }
242
+
243
+ /** Options for {@link ComponentContext.exec}. */
244
+ export interface ComponentExecOpts {
245
+ /** Piped to the command's stdin, then closed. */
246
+ stdin?: string;
247
+ /** Working directory inside the container (`docker exec -w`). */
248
+ cwd?: string;
249
+ /** Kill the exec after this long. Default 120_000. */
250
+ timeoutMs?: number;
251
+ }
252
+
253
+ /** What a service's `setup` hook receives. */
254
+ export interface ServiceSetupContext<
255
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
256
+ H extends Record<string, any> = Record<string, never>,
257
+ > extends ComponentContext {
258
+ /** The service's key in the services map (container + DNS name). */
259
+ name: string;
260
+ /** The record the service's `helpers` factory returned (cached — setup
261
+ * and tests share one instance), or `{}` when it ships none. */
262
+ helpers: H;
263
+ }
264
+
265
+ /** What a service's `helpers` factory receives. */
266
+ export interface ServiceHelpersContext extends ComponentContext {
267
+ /** The service's key in the services map (container + DNS name). */
268
+ name: string;
269
+ }
270
+
208
271
  /**
209
272
  * Same shape as `ServiceConfig` plus an optional `helpers` factory that
210
273
  * opens a namespace under `ctx.svc.<name>` inside tests. The factory
@@ -225,10 +288,12 @@ export interface ServiceDefinition<
225
288
  * Build the helpers exposed at `ctx.svc.<name>.<key>`. Called by the
226
289
  * daemon the first time a test touches this service; the result is
227
290
  * cached for the lifetime of the daemon (it survives snapshot/fork
228
- * along with the rest of daemon memory). `name` is the key the user
229
- * chose in the services map and matches the in-VM DNS name.
291
+ * along with the rest of daemon memory). `args.name` is the key the
292
+ * user chose in the services map and matches the in-VM DNS name; the
293
+ * rest of the {@link ServiceHelpersContext} (exec, projectRoot) lets a
294
+ * component reach into its containers without daemon internals.
230
295
  */
231
- helpers?: (args: { name: string }) => H | Promise<H>;
296
+ helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
232
297
  /**
233
298
  * One-shot setup after the container's `readyCheck` passes, before
234
299
  * dependents start and before any test runs. Awaited in-line with
@@ -238,9 +303,11 @@ export interface ServiceDefinition<
238
303
  *
239
304
  * `helpers` is the same record the `helpers` factory returns — building
240
305
  * it is cached, so `setup` and tests share one instance. If the service
241
- * doesn't declare `helpers`, the field is the empty object.
306
+ * doesn't declare `helpers`, the field is the empty object. The rest of
307
+ * the {@link ServiceSetupContext} carries `exec` (docker exec with
308
+ * stdin) and `projectRoot`/`readProjectFile` for project-file access.
242
309
  */
243
- setup?: (args: { name: string; helpers: H }) => void | Promise<void>;
310
+ setup?: (args: ServiceSetupContext<H>) => void | Promise<void>;
244
311
  }
245
312
 
246
313
  /** A services map — what users pass to `environment.services`. Entries
@@ -273,6 +340,329 @@ export type ServiceHandlesFor<S extends ServicesMap> = {
273
340
  * typed `ctx.svc` in test bodies is `ServiceHandlesFor<S>`. */
274
341
  export type ServiceHandles = Record<string, Record<string, unknown>>;
275
342
 
343
+ // ──────────────────────────────────────────────────────────────────────────
344
+ // Service groups — one services-map entry that expands to several services.
345
+ //
346
+ // A single container is a `ServiceConfig`/`ServiceDefinition`; a component
347
+ // that is inherently a *constellation* of containers (Supabase ≈ 7 of them)
348
+ // is a `ServiceGroup`. The user mounts the whole group under ONE key:
349
+ //
350
+ // services: { supabase: sb.group, app: { ... } }
351
+ //
352
+ // and `defineEnvironment` expands it: the group's `primary` part takes the
353
+ // group's own key (`supabase`), every other part lands at `<key>-<part>`
354
+ // (`supabase-db`, `supabase-auth`, …). Inside the group, `dependsOn`
355
+ // entries naming a relative part are rewritten to the final keys, so a
356
+ // group author never manually prefixes anything. Groups are pure sugar —
357
+ // they expand to plain services before validation, so the wire config the
358
+ // control plane sees is unchanged.
359
+ // ──────────────────────────────────────────────────────────────────────────
360
+
361
+ /** Symbol marking a value in the services map as a group to expand.
362
+ * Enumerable-symbol convention (like the ingress `provides` decls): it
363
+ * survives object spread but `JSON.stringify` drops it — not that a group
364
+ * ever reaches the wire; expansion happens before the config exists. */
365
+ const SERVICE_GROUP: unique symbol = Symbol.for("spectest.service.group");
366
+
367
+ /**
368
+ * Naming context handed to a group's `services` factory. Lets the factory
369
+ * embed *final* DNS names in env vars and config files without knowing the
370
+ * key the user will mount the group under.
371
+ */
372
+ export interface GroupNaming {
373
+ /** The services-map key the user chose for the group. */
374
+ name: string;
375
+ /** Final services-map key of a member part: `key(primary)` is `name`
376
+ * itself; any other part maps to `` `${name}-${part}` ``. */
377
+ key(part: string): string;
378
+ }
379
+
380
+ export interface ServiceGroupInput<
381
+ P extends ServicesMap,
382
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
383
+ H extends Record<string, any> = Record<string, never>,
384
+ Primary extends keyof P & string = keyof P & string,
385
+ > {
386
+ /**
387
+ * Build the group's member services, keyed by RELATIVE part name
388
+ * (`db`, `auth`, …). Called at expansion time with the final naming
389
+ * context, so strings that must carry final DNS names (connection
390
+ * URLs, embedded config) use `g.key("db")`. `dependsOn` entries that
391
+ * name a relative part are rewritten to final keys automatically
392
+ * (entries that don't match a part pass through untouched, so a group
393
+ * service may still depend on an outside service).
394
+ */
395
+ services: (g: GroupNaming) => P;
396
+ /**
397
+ * The part that represents the group: it takes the group's own map key
398
+ * (so `dependsOn: ["<groupKey>"]` from outside waits for it), and it
399
+ * carries the group's consolidated `helpers`/`setup`. The primary is
400
+ * made the group's dependency **sink** — every other member is added to
401
+ * its `dependsOn` — so "the primary is ready" means "the whole group is
402
+ * up, setup included". Consequently no member may depend on the
403
+ * primary (that would be a cycle); pick a gateway/front-door part.
404
+ */
405
+ primary: Primary;
406
+ /**
407
+ * The group's consolidated handle: helpers exposed at
408
+ * `ctx.svc.<groupKey>` — one typed surface for the whole group (e.g.
409
+ * `{ sql, url, anonKey }` for Supabase). Attached to the primary
410
+ * service; the primary part itself must not also declare `helpers`.
411
+ */
412
+ helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
413
+ /**
414
+ * Group-level setup: runs once every member service is ready (run →
415
+ * probe → per-part setup), before anything that `dependsOn` the group
416
+ * starts and before any test runs. The home for "the whole stack is
417
+ * up, now do X". Runs after the primary part's own `setup`, if any.
418
+ */
419
+ setup?: (args: ServiceSetupContext<H>) => void | Promise<void>;
420
+ /**
421
+ * Pin the services-map key the group must be mounted under. For
422
+ * components whose *derived* surface (connection URLs, env for the app
423
+ * under test) is computed from a name option before expansion — a
424
+ * mismatched key would silently split the two. Expansion errors with a
425
+ * pointer to the component's `name` option instead.
426
+ */
427
+ expectKey?: string;
428
+ }
429
+
430
+ /**
431
+ * A multi-service component — the value `serviceGroup(...)` returns, and
432
+ * what a constellation component (e.g. `supabase()`) hands you to put in
433
+ * the services map. Opaque; `defineEnvironment` expands it.
434
+ */
435
+ export interface ServiceGroup<
436
+ P extends ServicesMap = ServicesMap,
437
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
438
+ H extends Record<string, any> = Record<string, never>,
439
+ Primary extends keyof P & string = keyof P & string,
440
+ > extends ServiceGroupInput<P, H, Primary> {
441
+ readonly [SERVICE_GROUP]: true;
442
+ }
443
+
444
+ /**
445
+ * Define a multi-service group. See {@link ServiceGroupInput} for the
446
+ * fields; the result goes straight into a services map:
447
+ *
448
+ * ```ts
449
+ * const stack = serviceGroup({
450
+ * primary: "gateway",
451
+ * services: (g) => ({
452
+ * db: { image: ..., ... },
453
+ * gateway: { image: ..., env: { DB_URL: `postgres://${g.key("db")}:5432/db` },
454
+ * dependsOn: ["db"] },
455
+ * }),
456
+ * helpers: () => ({ url: "http://..." }),
457
+ * });
458
+ *
459
+ * defineEnvironment({ name: "app", services: { stack } });
460
+ * // expands to services `stack` (the gateway) + `stack-db`;
461
+ * // ctx.svc.stack is the helpers record.
462
+ * ```
463
+ */
464
+ export function serviceGroup<
465
+ P extends ServicesMap,
466
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
467
+ H extends Record<string, any> = Record<string, never>,
468
+ Primary extends keyof P & string = keyof P & string,
469
+ >(input: ServiceGroupInput<P, H, Primary>): ServiceGroup<P, H, Primary> {
470
+ if (typeof input.services !== "function") {
471
+ throw new Error("serviceGroup: `services` must be a factory function");
472
+ }
473
+ if (!input.primary || typeof input.primary !== "string") {
474
+ throw new Error("serviceGroup: `primary` (a relative part name) is required");
475
+ }
476
+ const group = { ...input } as ServiceGroup<P, H, Primary>;
477
+ Object.defineProperty(group, SERVICE_GROUP, {
478
+ value: true,
479
+ enumerable: true,
480
+ configurable: true,
481
+ writable: false,
482
+ });
483
+ return group;
484
+ }
485
+
486
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
487
+ function isServiceGroup(v: unknown): v is ServiceGroup<any, any, any> {
488
+ return (
489
+ typeof v === "object" &&
490
+ v !== null &&
491
+ (v as Record<symbol, unknown>)[SERVICE_GROUP] === true
492
+ );
493
+ }
494
+
495
+ /** What users pass as `services` to `defineEnvironment`: plain services
496
+ * and/or groups to expand. */
497
+ export type InputServicesMap = Record<
498
+ string,
499
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
500
+ ServiceConfig | ServiceGroup<any, any, any>
501
+ >;
502
+
503
+ type UnionToIntersection<U> = (
504
+ U extends unknown ? (x: U) => void : never
505
+ ) extends (x: infer I) => void
506
+ ? I
507
+ : never;
508
+
509
+ /** Flatten an intersection of records into one mapped type (which also
510
+ * gives it the implicit index signature `ServicesMap` needs). */
511
+ type Reify<T> = { [K in keyof T]: T[K] };
512
+
513
+ /** The primary part with the group's consolidated `helpers` grafted on,
514
+ * so `ServiceHandlesFor` surfaces the group handle at the group key. */
515
+ type PrimaryWithHandle<C, H> = [H] extends [Record<string, never>]
516
+ ? C
517
+ : Omit<C, "helpers"> & { helpers: (args: ServiceHelpersContext) => H };
518
+
519
+ type ExpandGroupEntry<K extends string, G> = G extends ServiceGroup<
520
+ infer P,
521
+ infer H,
522
+ infer Primary
523
+ >
524
+ ? {
525
+ [Q in Exclude<keyof P & string, Primary> as `${K}-${Q}`]: P[Q];
526
+ } & { [Q in K]: PrimaryWithHandle<P[Primary & keyof P], H> }
527
+ : never;
528
+
529
+ /**
530
+ * The services map after group expansion — what `ctx.svc` and the wire
531
+ * config are typed against. Groups expand to `<key>` (primary, carrying
532
+ * the group handle) + `<key>-<part>` entries; plain services pass through.
533
+ */
534
+ export type ExpandServices<SI extends InputServicesMap> = Reify<
535
+ UnionToIntersection<
536
+ {
537
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
538
+ [K in keyof SI & string]: SI[K] extends ServiceGroup<any, any, any>
539
+ ? ExpandGroupEntry<K, SI[K]>
540
+ : { [Q in K]: SI[K] };
541
+ }[keyof SI & string]
542
+ >
543
+ > extends infer S extends ServicesMap
544
+ ? S
545
+ : ServicesMap;
546
+
547
+ /**
548
+ * Runtime counterpart of {@link ExpandServices}: expand every group entry
549
+ * into plain services. Throws on key collisions, a missing primary,
550
+ * nested groups, a violated `expectKey`, and members depending on the
551
+ * primary (the primary is the group's sink — see
552
+ * {@link ServiceGroupInput.primary}).
553
+ */
554
+ function expandServiceGroups(input: InputServicesMap): ServicesMap {
555
+ const out: ServicesMap = {};
556
+ const ownerOf = new Map<string, string>();
557
+ const claim = (key: string, owner: string): void => {
558
+ const prior = ownerOf.get(key);
559
+ if (prior !== undefined) {
560
+ throw new Error(
561
+ `service key ${JSON.stringify(key)} is produced by both ${prior} and ${owner}`,
562
+ );
563
+ }
564
+ ownerOf.set(key, owner);
565
+ };
566
+
567
+ for (const [key, entry] of Object.entries(input)) {
568
+ if (!isServiceGroup(entry)) {
569
+ claim(key, "the services map");
570
+ out[key] = entry;
571
+ continue;
572
+ }
573
+ const owner = `group ${JSON.stringify(key)}`;
574
+ if (entry.expectKey !== undefined && entry.expectKey !== key) {
575
+ throw new Error(
576
+ `service group at key ${JSON.stringify(key)} expects to be mounted at ` +
577
+ `${JSON.stringify(entry.expectKey)} — its derived names (URLs, app env) were built ` +
578
+ `from that name. Mount it at ${JSON.stringify(entry.expectKey)}, or pass ` +
579
+ `\`name: ${JSON.stringify(key)}\` to the component so both agree.`,
580
+ );
581
+ }
582
+ const naming: GroupNaming = {
583
+ name: key,
584
+ key: (part: string) => (part === entry.primary ? key : `${key}-${part}`),
585
+ };
586
+ const parts: ServicesMap = entry.services(naming);
587
+ const partKeys = new Set(Object.keys(parts));
588
+ if (!partKeys.has(entry.primary)) {
589
+ throw new Error(
590
+ `${owner}: primary part ${JSON.stringify(entry.primary)} is not in the parts the ` +
591
+ `services factory returned (${[...partKeys].join(", ")})`,
592
+ );
593
+ }
594
+ // No member may depend on the primary (directly or transitively within
595
+ // the group): the primary is about to become the group's sink.
596
+ const reachesPrimary = (part: string, seen = new Set<string>()): boolean => {
597
+ if (seen.has(part)) return false;
598
+ seen.add(part);
599
+ for (const dep of parts[part]?.dependsOn ?? []) {
600
+ if (!partKeys.has(dep)) continue;
601
+ if (dep === entry.primary || reachesPrimary(dep, seen)) return true;
602
+ }
603
+ return false;
604
+ };
605
+ for (const part of partKeys) {
606
+ if (isServiceGroup(parts[part])) {
607
+ throw new Error(`${owner}: part ${JSON.stringify(part)} is itself a group — groups don't nest`);
608
+ }
609
+ if (part !== entry.primary && reachesPrimary(part)) {
610
+ throw new Error(
611
+ `${owner}: part ${JSON.stringify(part)} depends on the primary ` +
612
+ `${JSON.stringify(entry.primary)} — the primary must be the group's sink ` +
613
+ `(everything else becomes its dependency so "primary ready" means "group up")`,
614
+ );
615
+ }
616
+ }
617
+ for (const [part, svc] of Object.entries(parts)) {
618
+ const finalKey = naming.key(part);
619
+ claim(finalKey, owner);
620
+ // Rewrite relative dependsOn to final keys; a spread keeps function
621
+ // fields (helpers/setup) and the enumerable-symbol ingress decls.
622
+ const final: ServiceConfig = { ...svc };
623
+ if (svc.dependsOn?.length) {
624
+ final.dependsOn = svc.dependsOn.map((d) =>
625
+ partKeys.has(d) ? naming.key(d) : d,
626
+ );
627
+ }
628
+ if (part === entry.primary) {
629
+ // Sink: the primary waits for every other member, so an outside
630
+ // `dependsOn: ["<groupKey>"]` (and the group `setup` below) means
631
+ // the whole group. Members already in dependsOn stay put.
632
+ const deps = new Set(final.dependsOn ?? []);
633
+ for (const other of partKeys) {
634
+ if (other !== entry.primary) deps.add(naming.key(other));
635
+ }
636
+ final.dependsOn = [...deps];
637
+ const def = final as ServiceDefinition<Record<string, unknown>>;
638
+ if (entry.helpers) {
639
+ if (def.helpers) {
640
+ throw new Error(
641
+ `${owner}: both the group and its primary part declare \`helpers\` — ` +
642
+ `declare them once, on the group`,
643
+ );
644
+ }
645
+ def.helpers = entry.helpers as (
646
+ args: ServiceHelpersContext,
647
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
648
+ }
649
+ if (entry.setup) {
650
+ const partSetup = def.setup;
651
+ const groupSetup = entry.setup as (
652
+ args: ServiceSetupContext<Record<string, unknown>>,
653
+ ) => void | Promise<void>;
654
+ def.setup = async (args) => {
655
+ if (partSetup) await partSetup(args);
656
+ await groupSetup(args);
657
+ };
658
+ }
659
+ }
660
+ out[finalKey] = final;
661
+ }
662
+ }
663
+ return out;
664
+ }
665
+
276
666
  /**
277
667
  * One TLS-terminated hostname for a service. The daemon binds the
278
668
  * hostname on `:443` (with a leaf cert signed by the in-VM root CA)
@@ -304,6 +694,17 @@ export type ServiceImage =
304
694
  };
305
695
 
306
696
  export interface VolumeMount {
697
+ /**
698
+ * Named shared volume. Two services mounting the same `name` share one
699
+ * backing directory — the fit for sidecar pairs that exchange files
700
+ * (e.g. an image proxy reading what a storage API wrote). The directory
701
+ * lives in the per-env state tree, so it snapshots/forks with the rest
702
+ * of the environment and is torn down for fresh-state like any other
703
+ * volume. Names are environment-global: prefix with your service/group
704
+ * name in a reusable component so two instances never collide.
705
+ * Mutually exclusive with `source`.
706
+ */
707
+ name?: string;
307
708
  /**
308
709
  * Host path. Relative paths resolve under
309
710
  * `.spectest/volumes/<service>/`. Defaults to a path derived from `target`.
@@ -342,7 +743,25 @@ export interface FileMount {
342
743
 
343
744
  export type ReadyCheck =
344
745
  | { type: "tcp"; port: number; timeoutSecs?: number }
345
- | { type: "http"; port: number; path?: string; timeoutSecs?: number }
746
+ | {
747
+ type: "http";
748
+ port: number;
749
+ path?: string;
750
+ /**
751
+ * Extra request headers sent with each probe — for health endpoints
752
+ * behind auth (`{ Authorization: "Bearer …" }`). Keeps the probe
753
+ * image-agnostic where an `exec` + curl would depend on curl being
754
+ * in the image.
755
+ */
756
+ headers?: Record<string, string>;
757
+ /**
758
+ * Exact status code that counts as ready. Default: any 2xx. Use for
759
+ * endpoints whose healthy answer isn't 2xx (e.g. a root path that
760
+ * 301s or 401s once the server is actually up).
761
+ */
762
+ expectStatus?: number;
763
+ timeoutSecs?: number;
764
+ }
346
765
  /**
347
766
  * Run a shell command inside the container; exit 0 = ready. Used when
348
767
  * the readiness signal isn't reachable via plain TCP/HTTP from outside
@@ -451,6 +870,18 @@ function validateEnvironmentConfig<S extends ServicesMap>(
451
870
  );
452
871
  }
453
872
  }
873
+ for (const vol of svc.volumes ?? []) {
874
+ if (vol.name !== undefined && vol.source !== undefined) {
875
+ throw new Error(
876
+ `service "${name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\` — a named shared volume derives its backing dir from the name`,
877
+ );
878
+ }
879
+ if (vol.name !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(vol.name)) {
880
+ throw new Error(
881
+ `service "${name}" volume name ${JSON.stringify(vol.name)} must be alphanumeric plus [._-]`,
882
+ );
883
+ }
884
+ }
454
885
  for (const raw of svc.hostnames ?? []) {
455
886
  const h = raw.toLowerCase();
456
887
  if (!HOSTNAME_RE.test(h)) {
@@ -630,9 +1061,20 @@ export interface TestContext<
630
1061
  */
631
1062
  openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
632
1063
  /**
633
- * Open a headless browser. Backed by Chromium-over-CDP inside the VM.
634
- * Every view is auto-closed when the test finishes; call `.close()` to
635
- * release earlier if you're opening many.
1064
+ * Open the headless browser. Backed by Chromium-over-CDP inside the VM.
1065
+ *
1066
+ * There is ONE persistent browser per environment: every `ctx.browser()`
1067
+ * call returns it, and it stays alive across tests — the browser is part
1068
+ * of the state a test's snapshot captures, so a `dependsOn` child resumes
1069
+ * the exact live page its parent left (cookies, localStorage, signed-in
1070
+ * SPA state). Sign in once in a parent test; every descendant is already
1071
+ * signed in. Sibling tests fork from the same parent snapshot, so they
1072
+ * can't see each other's browsing. A test with no browser-using ancestor
1073
+ * gets a fresh browser on first call (first call's options win).
1074
+ *
1075
+ * `.close()` destroys the shared instance — the next `ctx.browser()`
1076
+ * starts fresh. Don't call it for routine cleanup; recording is detached
1077
+ * automatically at test end.
636
1078
  */
637
1079
  browser(opts?: BrowserOptions): Promise<Browser>;
638
1080
  /**
@@ -651,8 +1093,13 @@ export interface TestContext<
651
1093
  * ```
652
1094
  *
653
1095
  * The session emulates the latest iPhone (viewport + DPR + mobile UA +
654
- * touch) and the dashboard replays it inside a phone bezel. Auto-closed
655
- * when the test finishes.
1096
+ * touch) and the dashboard replays it inside a phone bezel.
1097
+ *
1098
+ * Sessions are persistent, one per app: like `ctx.browser()`, the live
1099
+ * session is captured in the test's snapshot, so a `dependsOn` child
1100
+ * picks up the app exactly where the parent left it (already signed in,
1101
+ * mid-flow) instead of reloading it. `.close()` discards the session;
1102
+ * the next `ctx.mobile(app)` opens the app fresh.
656
1103
  */
657
1104
  mobile(app: MobileApp): Promise<Mobile>;
658
1105
  /** The test's display name. */
@@ -1111,11 +1558,22 @@ export interface ProjectOpts<
1111
1558
  * creates a test — that's what makes `ctx.fakes.<key>` strongly typed.
1112
1559
  * `fakes` is stripped from `config` before it's stored/serialised, so the
1113
1560
  * wire `EnvironmentConfig` the Rust control plane sees never carries it.
1561
+ *
1562
+ * Unlike the wire {@link EnvironmentConfig}, `services` entries here may
1563
+ * be {@link ServiceGroup}s — they're expanded to plain services (primary
1564
+ * at the group's key, other parts at `<key>-<part>`) before validation.
1114
1565
  */
1115
1566
  export interface EnvironmentInput<
1116
- S extends ServicesMap,
1567
+ SI extends InputServicesMap,
1117
1568
  F extends FakesMap = FakesMap,
1118
- > extends EnvironmentConfig<S> {
1569
+ > {
1570
+ /** Human-friendly name for the environment, e.g. "my-app". */
1571
+ name: string;
1572
+ /** Services and/or service groups, keyed by name. See
1573
+ * {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
1574
+ services: SI;
1575
+ /** Sandbox timeout in seconds (default 1h). */
1576
+ timeoutSecs?: number;
1119
1577
  /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
1120
1578
  * shows up as `ctx.fakes.<key>` in tests, typed as that fake's helpers
1121
1579
  * record. */
@@ -1148,12 +1606,18 @@ export interface DefinedEnvironment<
1148
1606
  * produces the file's default export.
1149
1607
  */
1150
1608
  export function defineEnvironment<
1151
- S extends ServicesMap,
1609
+ SI extends InputServicesMap,
1152
1610
  F extends FakesMap = FakesMap,
1153
- >(input: EnvironmentInput<S, F>): DefinedEnvironment<S, F> {
1154
- // Split fakes off the wire config; only { name, services, timeoutSecs }
1611
+ >(input: EnvironmentInput<SI, F>): DefinedEnvironment<ExpandServices<SI>, F> {
1612
+ type S = ExpandServices<SI>;
1613
+ // Split fakes off the wire config and expand service groups; only
1614
+ // { name, services, timeoutSecs } — with plain, fully-expanded services —
1155
1615
  // is stored as `config` and shipped to the control plane.
1156
- const { fakes, ...config } = input;
1616
+ const { fakes, services: rawServices, ...rest } = input;
1617
+ const config: EnvironmentConfig<S> = {
1618
+ ...rest,
1619
+ services: expandServiceGroups(rawServices) as S,
1620
+ };
1157
1621
  validateEnvironmentConfig(config);
1158
1622
  if (fakes) validateFakes(config, fakes);
1159
1623
 
package/src/mobile.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // `accessibilityLabel` to `aria-label`, so `getByTestId`/`getByLabel` map to
15
15
  // plain DOM attribute selectors with no shimming.
16
16
 
17
- import { openMobileBackend } from "./browser.js";
17
+ import { acquirePersistentMobileBackend, openMobileBackend } from "./browser.js";
18
18
  import type {
19
19
  BrowserSessionRecorder,
20
20
  MobileBackend,
@@ -362,9 +362,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
362
362
  }
363
363
 
364
364
  /**
365
- * Open a phone-emulated session pointed at `url`. The daemon calls this from
366
- * `ctx.mobile(app)` with a per-session rrweb recorder; the resulting record
367
- * carries `frame: "mobile"` so the dashboard renders a phone bezel.
365
+ * Open an EPHEMERAL phone-emulated session pointed at `url` (`close()`
366
+ * destroys it). Library callers only — the daemon's `ctx.mobile(app)` goes
367
+ * through {@link openPersistentMobile} so sessions survive across tests.
368
368
  */
369
369
  export async function openMobile(opts: {
370
370
  url?: string;
@@ -377,3 +377,22 @@ export async function openMobile(opts: {
377
377
  });
378
378
  return wrapMobile(backend);
379
379
  }
380
+
381
+ /**
382
+ * Acquire the persistent phone-emulated session for an app (one per app
383
+ * URL, created on first use — see the "Persistent sessions" section in
384
+ * browser.ts). The daemon calls this from `ctx.mobile(app)` with a
385
+ * per-test rrweb recorder; the resulting record carries `frame: "mobile"`
386
+ * so the dashboard renders a phone bezel. `detach` is the test-end hook;
387
+ * `mobile.close()` destroys the session for real.
388
+ */
389
+ export async function openPersistentMobile(opts: {
390
+ url: string;
391
+ recorder: BrowserSessionRecorder | null;
392
+ }): Promise<{ mobile: Mobile; attached: boolean; detach(): Promise<void> }> {
393
+ const { browser, attached, detach } = await acquirePersistentMobileBackend(
394
+ opts.url,
395
+ opts.recorder,
396
+ );
397
+ return { mobile: wrapMobile(browser), attached, detach };
398
+ }