@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/package.json +1 -1
- package/src/browser.ts +309 -46
- package/src/components/email.ts +387 -0
- package/src/components/index.ts +15 -0
- package/src/components/k3s.ts +4 -16
- package/src/components/supabase.ts +1109 -0
- package/src/daemon.ts +392 -89
- package/src/index.ts +481 -17
- package/src/mobile.ts +23 -4
- package/src/recorder.ts +72 -1
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
|
|
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:
|
|
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:
|
|
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
|
-
| {
|
|
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
|
|
634
|
-
*
|
|
635
|
-
*
|
|
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.
|
|
655
|
-
*
|
|
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
|
-
|
|
1567
|
+
SI extends InputServicesMap,
|
|
1117
1568
|
F extends FakesMap = FakesMap,
|
|
1118
|
-
>
|
|
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
|
-
|
|
1609
|
+
SI extends InputServicesMap,
|
|
1152
1610
|
F extends FakesMap = FakesMap,
|
|
1153
|
-
>(input: EnvironmentInput<
|
|
1154
|
-
|
|
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, ...
|
|
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
|
|
366
|
-
* `ctx.mobile(app)`
|
|
367
|
-
*
|
|
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
|
+
}
|