@specific.dev/spectest 0.13.0 → 0.14.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)) {
@@ -1111,11 +1542,22 @@ export interface ProjectOpts<
1111
1542
  * creates a test — that's what makes `ctx.fakes.<key>` strongly typed.
1112
1543
  * `fakes` is stripped from `config` before it's stored/serialised, so the
1113
1544
  * wire `EnvironmentConfig` the Rust control plane sees never carries it.
1545
+ *
1546
+ * Unlike the wire {@link EnvironmentConfig}, `services` entries here may
1547
+ * be {@link ServiceGroup}s — they're expanded to plain services (primary
1548
+ * at the group's key, other parts at `<key>-<part>`) before validation.
1114
1549
  */
1115
1550
  export interface EnvironmentInput<
1116
- S extends ServicesMap,
1551
+ SI extends InputServicesMap,
1117
1552
  F extends FakesMap = FakesMap,
1118
- > extends EnvironmentConfig<S> {
1553
+ > {
1554
+ /** Human-friendly name for the environment, e.g. "my-app". */
1555
+ name: string;
1556
+ /** Services and/or service groups, keyed by name. See
1557
+ * {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
1558
+ services: SI;
1559
+ /** Sandbox timeout in seconds (default 1h). */
1560
+ timeoutSecs?: number;
1119
1561
  /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
1120
1562
  * shows up as `ctx.fakes.<key>` in tests, typed as that fake's helpers
1121
1563
  * record. */
@@ -1148,12 +1590,18 @@ export interface DefinedEnvironment<
1148
1590
  * produces the file's default export.
1149
1591
  */
1150
1592
  export function defineEnvironment<
1151
- S extends ServicesMap,
1593
+ SI extends InputServicesMap,
1152
1594
  F extends FakesMap = FakesMap,
1153
- >(input: EnvironmentInput<S, F>): DefinedEnvironment<S, F> {
1154
- // Split fakes off the wire config; only { name, services, timeoutSecs }
1595
+ >(input: EnvironmentInput<SI, F>): DefinedEnvironment<ExpandServices<SI>, F> {
1596
+ type S = ExpandServices<SI>;
1597
+ // Split fakes off the wire config and expand service groups; only
1598
+ // { name, services, timeoutSecs } — with plain, fully-expanded services —
1155
1599
  // is stored as `config` and shipped to the control plane.
1156
- const { fakes, ...config } = input;
1600
+ const { fakes, services: rawServices, ...rest } = input;
1601
+ const config: EnvironmentConfig<S> = {
1602
+ ...rest,
1603
+ services: expandServiceGroups(rawServices) as S,
1604
+ };
1157
1605
  validateEnvironmentConfig(config);
1158
1606
  if (fakes) validateFakes(config, fakes);
1159
1607