@specific.dev/spectest 0.52.0 → 0.54.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/dist/daemon.js CHANGED
@@ -30,6 +30,7 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
30
30
  // each easy to restate subtly differently.
31
31
  import { buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
32
32
  import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
33
+ import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
33
34
  import { summarizeBuildKit } from "./harness/buildkit-progress.js";
34
35
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
35
36
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
@@ -102,15 +103,13 @@ const CA_BUNDLE_PATH = process.env.SPECTEST_CA_BUNDLE_PATH ?? "/etc/spectest/ca-
102
103
  // after generating the CA, so this normally already carries both halves.
103
104
  const SYSTEM_CA_BUNDLE = process.env.SPECTEST_SYSTEM_CA_BUNDLE ?? "/etc/ssl/certs/ca-certificates.crt";
104
105
  let loaded = null;
106
+ /** The catalogue mapping lives in `harness/catalogue.ts`; these wrappers
107
+ * keep the call sites reading off the suite. */
105
108
  function casesMetadata(suite) {
106
- if (!suite)
107
- return [];
108
- return suite.tests.map((t) => ({
109
- id: t.id,
110
- name: t.name,
111
- dependsOn: t.dependsOn?.id,
112
- timeoutMs: t.timeoutMs,
113
- }));
109
+ return catalogueCases(suite?.tests);
110
+ }
111
+ function groupsMetadata(suite) {
112
+ return catalogueGroups(suite?.tests);
114
113
  }
115
114
  // Display-only summary of the project's fakes for the control plane (folded
116
115
  // into the env config's `fakes`, surfaced on the run page). Fakes aren't part
@@ -270,11 +269,14 @@ function docker(args, timeoutMs, env) {
270
269
  * the full captured output + exit code, so existing error handling and
271
270
  * post-hoc parsing (`summarizeBuildKit`) are unchanged.
272
271
  */
273
- function shxStream(file, args, timeoutMs, env, onLine) {
272
+ function shxStream(file, args, timeoutMs, env, onLine, stdin) {
274
273
  return new Promise((resolve) => {
275
274
  const child = spawn(file, args, {
276
275
  env: env ? { ...process.env, ...env } : process.env,
277
276
  });
277
+ if (stdin !== undefined) {
278
+ child.stdin?.end(stdin);
279
+ }
278
280
  let stdout = "";
279
281
  let stderr = "";
280
282
  let buf = "";
@@ -416,6 +418,8 @@ async function hasBuildx() {
416
418
  // in-VM builder, so a missing/dead buildkitd just means slower builds.
417
419
  const REMOTE_BUILDER_ADDR = process.env.SPECTEST_BUILDKIT_ADDR ?? "tcp://10.42.0.1:1234";
418
420
  const REMOTE_BUILDER_NAME = "spectest-remote";
421
+ /** Parent of the per-build buildx config dirs (see isolatedBuildxConfig).
422
+ * On tmpfs: each holds a builder stub and an 8-byte node id. */
419
423
  let _remoteBuilder;
420
424
  async function ensureRemoteBuilder() {
421
425
  if (_remoteBuilder !== undefined)
@@ -829,16 +833,8 @@ async function prepareServiceImage(svc, opts) {
829
833
  }
830
834
  return buildServiceImage(svc.name, image, tag);
831
835
  }
832
- /**
833
- * What the folded CA step prints when it could not write the trust store
834
- * at all — the one outcome that still needs {@link ensureCaTrustedImage},
835
- * which can take root for the write.
836
- *
837
- * The step assembles this prefix from a shell variable so that the marker
838
- * appears ONLY in the step's output. BuildKit echoes each instruction into
839
- * the same log verbatim, so a marker written literally in the RUN would
840
- * match on every build whether or not the step ever printed it.
841
- */
836
+ /** Printed by the folded CA step when it could not write the trust
837
+ * store at all — the one outcome that still needs the derivative build. */
842
838
  const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
843
839
  /**
844
840
  * The CA-trust steps as a suffix appended to a dockerfile service's OWN
@@ -1309,6 +1305,44 @@ const INGRESS_HTTP_SERVERS = new Map();
1309
1305
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1310
1306
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1311
1307
  const INGRESS_HTTPS_SERVERS = new Map();
1308
+ /**
1309
+ * Servers replaced by a rebind and now draining. On Bun 1.3.14 a request
1310
+ * arriving on a kept-alive connection of a `stop(false)`-drained server
1311
+ * dispatches into freed per-server state and can SEGFAULT the process
1312
+ * (use-after-free class fixed upstream by oven-sh/bun#36790, first in Bun
1313
+ * 1.4.0; observed here as `panic: Segmentation fault at address 0xA` in
1314
+ * `server.zig onRequestFor` on ~2-3 % of runtime-TLS rebinds). Until the
1315
+ * Bun bump lands, shrink the number of requests a drained server can ever
1316
+ * see: every response it still serves carries `Connection: close` (one
1317
+ * more request per surviving connection, not unlimited), and a grace timer
1318
+ * force-closes whatever is left ({@link REBIND_DRAIN_GRACE_MS}).
1319
+ */
1320
+ const DRAINING_INGRESS = new WeakSet();
1321
+ /** How long a drained listener may keep serving in-flight work before its
1322
+ * remaining connections are force-closed. Long enough for a slow proxied
1323
+ * response to finish, short enough to bound the 1.3.14 UAF window. */
1324
+ const REBIND_DRAIN_GRACE_MS = 15_000;
1325
+ /** Stamp `Connection: close` on a response served by a draining listener so
1326
+ * the kept-alive connection retires instead of lingering as a UAF trigger.
1327
+ * Proxied responses can carry immutable headers; rewrap when needed. */
1328
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1329
+ function withDrainClose(server, res) {
1330
+ if (!DRAINING_INGRESS.has(server))
1331
+ return res;
1332
+ try {
1333
+ res.headers.set("connection", "close");
1334
+ return res;
1335
+ }
1336
+ catch {
1337
+ const headers = new Headers(res.headers);
1338
+ headers.set("connection", "close");
1339
+ return new Response(res.body, {
1340
+ status: res.status,
1341
+ statusText: res.statusText,
1342
+ headers,
1343
+ });
1344
+ }
1345
+ }
1312
1346
  /**
1313
1347
  * The live ingress tables — per-port routes and the :443 SNI cert table.
1314
1348
  *
@@ -1631,6 +1665,23 @@ function rebindHttpsListener(Bun) {
1631
1665
  // upload through ingress must not be collateral damage of another
1632
1666
  // service being provisioned.
1633
1667
  old.stop(false);
1668
+ // Bun 1.3.14 landmine: a request arriving later on one of the old
1669
+ // server's kept-alive connections dispatches into freed state and can
1670
+ // segfault the daemon (see {@link DRAINING_INGRESS}). Mark it so any
1671
+ // response it still serves closes its connection, and force-close the
1672
+ // stragglers once in-flight work has had a fair window to finish.
1673
+ DRAINING_INGRESS.add(old);
1674
+ const graceTimer = setTimeout(() => {
1675
+ try {
1676
+ old.stop(true);
1677
+ }
1678
+ catch {
1679
+ /* already fully stopped */
1680
+ }
1681
+ }, REBIND_DRAIN_GRACE_MS);
1682
+ // Don't let the grace timer keep the process alive on shutdown.
1683
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1684
+ graceTimer.unref?.();
1634
1685
  }
1635
1686
  catch (err) {
1636
1687
  // eslint-disable-next-line no-console
@@ -1770,7 +1821,7 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
1770
1821
  // short-lived, leaked-connection risk is bounded by the fork.
1771
1822
  idleTimeout: 0,
1772
1823
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1773
- fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
1824
+ fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto).then((res) => withDrainClose(server, res)),
1774
1825
  websocket: {
1775
1826
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1776
1827
  async open(ws) {
@@ -3312,6 +3363,26 @@ function describeRequestBody(input, init) {
3312
3363
  }
3313
3364
  return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
3314
3365
  }
3366
+ /**
3367
+ * Marks an error thrown by the instrumented fetch as *transport-level* —
3368
+ * the connection itself failed (refused, unresolvable, reset) before any
3369
+ * HTTP reply existed. `fetch` never rejects for an HTTP status, so every
3370
+ * rejection short of an abort is transport. `Symbol.for` so a duplicated
3371
+ * SDK module instance (the bun hardlink landmine) still recognises it.
3372
+ *
3373
+ * Why it exists: `ctx.poll` waits for convergence, and right after a fork
3374
+ * restore the guest can serve a ~10 s window where a connect or a DNS
3375
+ * lookup fails once and then heals (measured 2026-08-21: a poll's first
3376
+ * fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
3377
+ * 1 while attempt 2 would have passed). During a poll, a dead connection
3378
+ * is just "not ready yet"; outside one it stays a hard error.
3379
+ */
3380
+ const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
3381
+ function isTransportError(err) {
3382
+ return (typeof err === "object" &&
3383
+ err !== null &&
3384
+ err[TRANSPORT_ERROR] === true);
3385
+ }
3315
3386
  /**
3316
3387
  * Install a fetch wrapper on `globalThis` that emits HTTP events into the
3317
3388
  * active recorder. Returns a restore function. Calls outside of a running
@@ -3382,6 +3453,20 @@ function installFetchWrapper() {
3382
3453
  durationMs: Date.now() - start,
3383
3454
  error: e?.message ?? String(err),
3384
3455
  }, resv);
3456
+ // Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
3457
+ // is the caller's own signal (their AbortController or their
3458
+ // AbortSignal.timeout) — their semantics, never retried for them.
3459
+ if (typeof err === "object" &&
3460
+ err !== null &&
3461
+ e?.name !== "AbortError" &&
3462
+ e?.name !== "TimeoutError") {
3463
+ try {
3464
+ err[TRANSPORT_ERROR] = true;
3465
+ }
3466
+ catch {
3467
+ /* frozen error object — stays a hard error */
3468
+ }
3469
+ }
3385
3470
  throw err;
3386
3471
  }
3387
3472
  };
@@ -3593,6 +3678,7 @@ async function pollCall(description, fn, opts) {
3593
3678
  let value;
3594
3679
  let success = false;
3595
3680
  let predicateError;
3681
+ let lastTransportError;
3596
3682
  // Record all iterations normally, but keep only the newest one: a new
3597
3683
  // attempt drops the events the previous attempt emitted, so the timeline
3598
3684
  // never fills with polling noise. Whichever attempt is last when the loop
@@ -3622,8 +3708,19 @@ async function pollCall(description, fn, opts) {
3622
3708
  }
3623
3709
  }
3624
3710
  catch (err) {
3625
- predicateError = err;
3626
- break;
3711
+ // A transport-level fetch failure (connection refused, DNS miss —
3712
+ // see TRANSPORT_ERROR) is "not ready yet", not a verdict: polls wait
3713
+ // for convergence, and services legitimately refuse connections
3714
+ // while they come up. Keep polling; the kept last attempt's http
3715
+ // event carries the error for the timeline. Anything else — an
3716
+ // assertion, a TypeError, a user abort — stays fatal on attempt 1.
3717
+ if (isTransportError(err)) {
3718
+ lastTransportError = err;
3719
+ }
3720
+ else {
3721
+ predicateError = err;
3722
+ break;
3723
+ }
3627
3724
  }
3628
3725
  if (Date.now() - start + intervalMs > timeoutMs)
3629
3726
  break;
@@ -3633,7 +3730,9 @@ async function pollCall(description, fn, opts) {
3633
3730
  ? (predicateError?.message ?? String(predicateError))
3634
3731
  : success
3635
3732
  ? undefined
3636
- : `timed out after ${timeoutMs}ms`;
3733
+ : lastTransportError !== undefined
3734
+ ? `timed out after ${timeoutMs}ms (last attempt: ${lastTransportError?.message ?? String(lastTransportError)})`
3735
+ : `timed out after ${timeoutMs}ms`;
3637
3736
  const seq = recordWait({
3638
3737
  description,
3639
3738
  attempts,
@@ -3652,7 +3751,10 @@ async function pollCall(description, fn, opts) {
3652
3751
  if (success) {
3653
3752
  return wrap(value, seq);
3654
3753
  }
3655
- throw new Error(`poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`);
3754
+ const lastAttempt = lastTransportError !== undefined
3755
+ ? `; last attempt: ${lastTransportError?.message ?? String(lastTransportError)}`
3756
+ : "";
3757
+ throw new Error(`poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts${lastAttempt})`);
3656
3758
  }
3657
3759
  function captureConsole(chunks) {
3658
3760
  const methods = ["log", "info", "warn", "error", "debug"];
@@ -4640,6 +4742,7 @@ function loadedSummary(l) {
4640
4742
  return {
4641
4743
  environment: l.project.environment,
4642
4744
  cases: casesMetadata(l.project.tests),
4745
+ groups: groupsMetadata(l.project.tests),
4643
4746
  fakes: fakesSummary(l.project),
4644
4747
  };
4645
4748
  }
@@ -4703,6 +4806,7 @@ export function harnessMethods(state) {
4703
4806
  return {
4704
4807
  environment: proj.environment,
4705
4808
  cases: casesMetadata(proj.tests),
4809
+ groups: groupsMetadata(proj.tests),
4706
4810
  fakes: fakesSummary(proj),
4707
4811
  };
4708
4812
  },
@@ -4737,7 +4841,10 @@ export function harnessMethods(state) {
4737
4841
  return loadedSummary(requireLoaded());
4738
4842
  },
4739
4843
  envConfig: async () => requireLoaded().project.environment,
4740
- cases: async () => ({ cases: casesMetadata(requireLoaded().project.tests) }),
4844
+ cases: async () => ({
4845
+ cases: casesMetadata(requireLoaded().project.tests),
4846
+ groups: groupsMetadata(requireLoaded().project.tests),
4847
+ }),
4741
4848
  // Union of platform secret refs the loaded fakes declare (replayFake's
4742
4849
  // `secretRefs`). The control plane resolves these server-side and pushes
4743
4850
  // the values on the eval path only. Empty if nothing's loaded.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The test catalogue the control plane reads: one entry per case, plus the
3
+ * display-only groups.
4
+ *
5
+ * Ported out of `daemon.ts`. Three harness methods (`load`, `loadTests`,
6
+ * `cases`) return this, so the mapping lives in one place.
7
+ */
8
+ /** The subset of a `TestCase` the catalogue describes. Structural, so the
9
+ * mapping needs no import from the SDK entry point. */
10
+ export interface CatalogueCase {
11
+ id: string;
12
+ name: string;
13
+ dependsOn?: {
14
+ id: string;
15
+ };
16
+ timeoutMs?: number;
17
+ group?: {
18
+ id: string;
19
+ name: string;
20
+ dependsOn?: {
21
+ id: string;
22
+ };
23
+ };
24
+ }
25
+ export interface CaseMeta {
26
+ id: string;
27
+ name: string;
28
+ dependsOn?: string;
29
+ timeoutMs?: number;
30
+ /** The group this case is shown in, if any. Display only — a group never
31
+ * changes the DAG, which `dependsOn` still describes in full. */
32
+ groupId?: string;
33
+ }
34
+ /** A display-only test group (`env.group(...)`), for the CLI tree and the
35
+ * dashboard. `dependsOn` is the group's own level: every member of the
36
+ * group depends on that same case (or on nothing, at the top level). */
37
+ export interface GroupMeta {
38
+ id: string;
39
+ name: string;
40
+ dependsOn?: string;
41
+ }
42
+ export declare function casesMetadata(cases: readonly CatalogueCase[] | undefined): CaseMeta[];
43
+ /**
44
+ * The groups the suite uses, in the order their first member appears —
45
+ * which is the order both surfaces render in.
46
+ *
47
+ * A group is a value the tests point at, not a registry, so the list is
48
+ * derived here. Members of one group hold the same group value, and its id
49
+ * is derived from its name and its parent, so keying by id collapses them
50
+ * to one entry.
51
+ */
52
+ export declare function groupsMetadata(cases: readonly CatalogueCase[] | undefined): GroupMeta[];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The test catalogue the control plane reads: one entry per case, plus the
3
+ * display-only groups.
4
+ *
5
+ * Ported out of `daemon.ts`. Three harness methods (`load`, `loadTests`,
6
+ * `cases`) return this, so the mapping lives in one place.
7
+ */
8
+ export function casesMetadata(cases) {
9
+ if (!cases)
10
+ return [];
11
+ return cases.map((t) => ({
12
+ id: t.id,
13
+ name: t.name,
14
+ dependsOn: t.dependsOn?.id,
15
+ timeoutMs: t.timeoutMs,
16
+ groupId: t.group?.id,
17
+ }));
18
+ }
19
+ /**
20
+ * The groups the suite uses, in the order their first member appears —
21
+ * which is the order both surfaces render in.
22
+ *
23
+ * A group is a value the tests point at, not a registry, so the list is
24
+ * derived here. Members of one group hold the same group value, and its id
25
+ * is derived from its name and its parent, so keying by id collapses them
26
+ * to one entry.
27
+ */
28
+ export function groupsMetadata(cases) {
29
+ if (!cases)
30
+ return [];
31
+ const groups = new Map();
32
+ for (const t of cases) {
33
+ const g = t.group;
34
+ if (!g || groups.has(g.id))
35
+ continue;
36
+ groups.set(g.id, { id: g.id, name: g.name, dependsOn: g.dependsOn?.id });
37
+ }
38
+ return [...groups.values()];
39
+ }
package/dist/index.d.ts CHANGED
@@ -868,13 +868,69 @@ export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F ex
868
868
  readonly name: string;
869
869
  /** Parent test, if any. Single-parent for now. */
870
870
  readonly dependsOn?: TestCase<unknown, S, F>;
871
+ /** The group this test is shown in, if any — set by the `group`
872
+ * option, which also supplies `dependsOn`. Display only: a group
873
+ * never changes how the suite runs. See {@link TestGroup}. */
874
+ readonly group?: TestGroup<unknown, S, F>;
871
875
  /** Override the default per-test timeout (default 60s). */
872
876
  readonly timeoutMs?: number;
873
877
  /** @internal — the body that the in-sandbox daemon invokes. */
874
878
  readonly run: TestFn<T, unknown, S, F>;
875
879
  }
880
+ /**
881
+ * A named group of sibling tests, created by `env.group(...)`. A group is
882
+ * purely informative — the CLI and the dashboard show its name above the
883
+ * tests in it, and nothing about the run changes.
884
+ *
885
+ * A group holds tests at ONE level of the tree: every member shares the
886
+ * group's own parent. That is why a member declares `group` INSTEAD OF
887
+ * `dependsOn` — the group supplies the parent, so a group can never span
888
+ * two levels. The descendants of a member are shown inside the group as
889
+ * well, but they declare only their own parent, as they always did.
890
+ *
891
+ * `P` is the return type of the parent test, so a member's `ctx.parent`
892
+ * is typed exactly as if it had named `dependsOn` itself.
893
+ *
894
+ * ```ts
895
+ * export const billing = env.group("Billing"); // top level
896
+ * export const createInvoice = env.test("create invoice", { group: billing }, fn);
897
+ * // A child. It is shown inside "Billing" already, so it names its parent.
898
+ * export const refund = env.test("refund", { dependsOn: createInvoice }, fn);
899
+ * // Groups nest through tests: this one sits under createInvoice.
900
+ * export const errors = env.group("Invoice errors", { dependsOn: createInvoice });
901
+ * ```
902
+ */
903
+ export interface TestGroup<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
904
+ /** Stable id — `<parent-case-id>/<slug of name>`, or the bare slug at
905
+ * top level. Derived from the name and the parent, never minted, so it
906
+ * is the same in every run: the dashboard and the run diff key on it.
907
+ * Qualifying by the parent is what lets the same name appear in
908
+ * different parts of the tree. */
909
+ readonly id: string;
910
+ readonly name: string;
911
+ /** The parent test every member of this group depends on. `undefined`
912
+ * for a top-level group. */
913
+ readonly dependsOn?: TestCase<P, S, F>;
914
+ }
915
+ /** Options for {@link DefinedEnvironment.group}. */
916
+ export interface GroupOpts<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
917
+ /** The parent every member of the group depends on. Omit it for a
918
+ * top-level group. */
919
+ dependsOn?: TestCase<P, S, F>;
920
+ }
921
+ /** Test options that name the parent directly. `group` is `never` here,
922
+ * so giving both options is a compile error rather than a rule the
923
+ * suite has to check at load time. */
876
924
  export interface TestOpts<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
877
925
  dependsOn?: TestCase<P, S, F>;
926
+ group?: never;
927
+ timeoutMs?: number;
928
+ }
929
+ /** Test options that name a group. The group supplies the parent, so
930
+ * `dependsOn` is `never` here — see {@link TestOpts}. */
931
+ export interface GroupedTestOpts<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
932
+ group: TestGroup<P, S, F>;
933
+ dependsOn?: never;
878
934
  timeoutMs?: number;
879
935
  }
880
936
  export type TestFn<T = void, P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> = (ctx: TestContext<P, S, F>) => T | Promise<T>;
@@ -1226,6 +1282,7 @@ export type FakeHandlesFor<F extends FakesMap> = string extends keyof F ? Record
1226
1282
  export interface TypedTest<S extends ServicesMap, F extends FakesMap = FakesMap> {
1227
1283
  <T = void>(name: string, fn: TestFn<T, undefined, S, F>): TestCase<T, S, F>;
1228
1284
  <T = void, P = undefined>(name: string, opts: TestOpts<P, S, F>, fn: TestFn<T, P, S, F>): TestCase<T, S, F>;
1285
+ <T = void, P = undefined>(name: string, opts: GroupedTestOpts<P, S, F>, fn: TestFn<T, P, S, F>): TestCase<T, S, F>;
1229
1286
  }
1230
1287
  /**
1231
1288
  * A complete project definition: an environment, an optional one-shot
@@ -1341,6 +1398,21 @@ export interface DefinedEnvironment<S extends ServicesMap, F extends FakesMap =
1341
1398
  * `ctx.fakes.<key>` are strongly typed against the services and fakes
1342
1399
  * maps. */
1343
1400
  readonly test: TypedTest<S, F>;
1401
+ /**
1402
+ * Declare a group of sibling tests, for display only. Pass the group to
1403
+ * a test's `group` option instead of `dependsOn` — the group carries the
1404
+ * parent, so every member sits at one level of the tree.
1405
+ *
1406
+ * ```ts
1407
+ * export const billing = env.group("Billing");
1408
+ * export const errors = env.group("Errors", { dependsOn: createInvoice });
1409
+ * ```
1410
+ *
1411
+ * Two groups with the same name AND the same parent are an error:
1412
+ * export one group value and import it where you need it. The same name
1413
+ * under a different parent is fine.
1414
+ */
1415
+ group<P = undefined>(name: string, opts?: GroupOpts<P, S, F>): TestGroup<P, S, F>;
1344
1416
  /** Bundle this environment with a test suite into the project default
1345
1417
  * export. Pass tests as a plain array for the common case, or an
1346
1418
  * options bag to attach project-level `setup`. (Fakes are declared on
package/dist/index.js CHANGED
@@ -337,6 +337,27 @@ export function defineEnvironment(input) {
337
337
  registry.push(tc);
338
338
  return tc;
339
339
  });
340
+ // Group ids are derived (`<parent-case-id>/<slug>`), never minted, so a
341
+ // group keeps its id across runs — the dashboard and the run diff key on
342
+ // it. Qualifying by the parent is what lets the same name be used in
343
+ // different parts of the tree; only two groups under the SAME parent
344
+ // collide, and that is the one case we refuse. Checking at creation time
345
+ // (rather than in `validateSuite`) puts the error at the `env.group` call
346
+ // that is wrong, and catches a group that no test has joined yet.
347
+ const groupIds = new Set();
348
+ const group = (name, opts) => {
349
+ const parent = opts?.dependsOn;
350
+ const slug = slugify(name, "group");
351
+ const id = parent ? `${parent.id}/${slug}` : slug;
352
+ if (groupIds.has(id)) {
353
+ throw new Error(`group ${JSON.stringify(name)} is declared twice${parent ? ` under "${parent.name}"` : " at the top level"} — export one group value and import it in both places`);
354
+ }
355
+ groupIds.add(id);
356
+ const g = parent
357
+ ? { id, name, dependsOn: parent }
358
+ : { id, name };
359
+ return g;
360
+ };
340
361
  function project(arg) {
341
362
  const opts = Array.isArray(arg)
342
363
  ? { tests: arg }
@@ -371,6 +392,7 @@ export function defineEnvironment(input) {
371
392
  return {
372
393
  config,
373
394
  test,
395
+ group,
374
396
  project,
375
397
  };
376
398
  }
@@ -430,7 +452,12 @@ maybeFn) {
430
452
  return {
431
453
  id: slugify(name),
432
454
  name,
433
- dependsOn: opts.dependsOn,
455
+ // `group` and `dependsOn` are mutually exclusive in the types, so at
456
+ // most one of these is set. A grouped test takes the group's parent —
457
+ // which is what keeps a group to one level of the tree, with no rule
458
+ // for the suite to check.
459
+ dependsOn: opts.dependsOn ?? opts.group?.dependsOn,
460
+ group: opts.group,
434
461
  timeoutMs: opts.timeoutMs,
435
462
  run: fn,
436
463
  };
@@ -453,13 +480,13 @@ function validateSuite(suite) {
453
480
  }
454
481
  return suite;
455
482
  }
456
- function slugify(name) {
483
+ function slugify(name, kind = "test") {
457
484
  const slug = name
458
485
  .toLowerCase()
459
486
  .replace(/[^a-z0-9]+/g, "-")
460
487
  .replace(/^-+|-+$/g, "");
461
488
  if (!slug) {
462
- throw new Error(`cannot derive id from test name ${JSON.stringify(name)}`);
489
+ throw new Error(`cannot derive id from ${kind} name ${JSON.stringify(name)}`);
463
490
  }
464
491
  return slug;
465
492
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -50,6 +50,12 @@ import {
50
50
  unionDockerignore,
51
51
  } from "./harness/build-context.js";
52
52
  import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
53
+ import {
54
+ casesMetadata as catalogueCases,
55
+ groupsMetadata as catalogueGroups,
56
+ type CaseMeta,
57
+ type GroupMeta,
58
+ } from "./harness/catalogue.js";
53
59
  import { summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
54
60
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
55
61
  import {
@@ -137,6 +143,7 @@ import {
137
143
  import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
138
144
  import type { Wrapped, WrappedResponse } from "./inspect.js";
139
145
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
146
+ import { generateId } from "./ids.js";
140
147
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
141
148
 
142
149
  import type {
@@ -247,21 +254,14 @@ interface Loaded {
247
254
 
248
255
  let loaded: Loaded | null = null;
249
256
 
250
- interface CaseMeta {
251
- id: string;
252
- name: string;
253
- dependsOn?: string;
254
- timeoutMs?: number;
257
+ /** The catalogue mapping lives in `harness/catalogue.ts`; these wrappers
258
+ * keep the call sites reading off the suite. */
259
+ function casesMetadata(suite: TestSuite | undefined): CaseMeta[] {
260
+ return catalogueCases(suite?.tests);
255
261
  }
256
262
 
257
- function casesMetadata(suite: TestSuite | undefined): CaseMeta[] {
258
- if (!suite) return [];
259
- return suite.tests.map((t) => ({
260
- id: t.id,
261
- name: t.name,
262
- dependsOn: t.dependsOn?.id,
263
- timeoutMs: t.timeoutMs,
264
- }));
263
+ function groupsMetadata(suite: TestSuite | undefined): GroupMeta[] {
264
+ return catalogueGroups(suite?.tests);
265
265
  }
266
266
 
267
267
  interface FakeMeta {
@@ -468,11 +468,15 @@ function shxStream(
468
468
  timeoutMs: number | undefined,
469
469
  env: Record<string, string> | undefined,
470
470
  onLine: (line: string) => void,
471
+ stdin?: string,
471
472
  ): Promise<CmdResult> {
472
473
  return new Promise((resolve) => {
473
474
  const child = spawn(file, args, {
474
475
  env: env ? { ...process.env, ...env } : process.env,
475
476
  });
477
+ if (stdin !== undefined) {
478
+ child.stdin?.end(stdin);
479
+ }
476
480
  let stdout = "";
477
481
  let stderr = "";
478
482
  let buf = "";
@@ -649,6 +653,8 @@ async function hasBuildx(): Promise<boolean> {
649
653
  // in-VM builder, so a missing/dead buildkitd just means slower builds.
650
654
  const REMOTE_BUILDER_ADDR = process.env.SPECTEST_BUILDKIT_ADDR ?? "tcp://10.42.0.1:1234";
651
655
  const REMOTE_BUILDER_NAME = "spectest-remote";
656
+ /** Parent of the per-build buildx config dirs (see isolatedBuildxConfig).
657
+ * On tmpfs: each holds a builder stub and an 8-byte node id. */
652
658
  let _remoteBuilder: boolean | undefined;
653
659
  async function ensureRemoteBuilder(): Promise<boolean> {
654
660
  if (_remoteBuilder !== undefined) return _remoteBuilder;
@@ -1116,16 +1122,8 @@ async function prepareServiceImage(
1116
1122
  return buildServiceImage(svc.name, image, tag);
1117
1123
  }
1118
1124
 
1119
- /**
1120
- * What the folded CA step prints when it could not write the trust store
1121
- * at all — the one outcome that still needs {@link ensureCaTrustedImage},
1122
- * which can take root for the write.
1123
- *
1124
- * The step assembles this prefix from a shell variable so that the marker
1125
- * appears ONLY in the step's output. BuildKit echoes each instruction into
1126
- * the same log verbatim, so a marker written literally in the RUN would
1127
- * match on every build whether or not the step ever printed it.
1128
- */
1125
+ /** Printed by the folded CA step when it could not write the trust
1126
+ * store at all — the one outcome that still needs the derivative build. */
1129
1127
  const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
1130
1128
 
1131
1129
  /**
@@ -1302,13 +1300,13 @@ async function runServiceBuild(
1302
1300
  });
1303
1301
  return;
1304
1302
  }
1305
- m = line.match(/^Step (\d+\/\d+)\s*:\s*(.+)$/);
1306
- if (m) {
1307
- progressService(name, {
1308
- status: "building",
1309
- detail: `step ${m[1]} ${m[2].trim().slice(0, 60)}`,
1310
- });
1311
- }
1303
+ m = line.match(/^Step (\d+\/\d+)\s*:\s*(.+)$/);
1304
+ if (m) {
1305
+ progressService(name, {
1306
+ status: "building",
1307
+ detail: `step ${m[1]} ${m[2].trim().slice(0, 60)}`,
1308
+ });
1309
+ }
1312
1310
  });
1313
1311
  const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
1314
1312
  if (build.code !== 0) {
@@ -1679,6 +1677,43 @@ const INGRESS_HTTP_SERVERS = new Map<number, any>();
1679
1677
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1680
1678
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1681
1679
  const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1680
+ /**
1681
+ * Servers replaced by a rebind and now draining. On Bun 1.3.14 a request
1682
+ * arriving on a kept-alive connection of a `stop(false)`-drained server
1683
+ * dispatches into freed per-server state and can SEGFAULT the process
1684
+ * (use-after-free class fixed upstream by oven-sh/bun#36790, first in Bun
1685
+ * 1.4.0; observed here as `panic: Segmentation fault at address 0xA` in
1686
+ * `server.zig onRequestFor` on ~2-3 % of runtime-TLS rebinds). Until the
1687
+ * Bun bump lands, shrink the number of requests a drained server can ever
1688
+ * see: every response it still serves carries `Connection: close` (one
1689
+ * more request per surviving connection, not unlimited), and a grace timer
1690
+ * force-closes whatever is left ({@link REBIND_DRAIN_GRACE_MS}).
1691
+ */
1692
+ const DRAINING_INGRESS = new WeakSet<object>();
1693
+ /** How long a drained listener may keep serving in-flight work before its
1694
+ * remaining connections are force-closed. Long enough for a slow proxied
1695
+ * response to finish, short enough to bound the 1.3.14 UAF window. */
1696
+ const REBIND_DRAIN_GRACE_MS = 15_000;
1697
+
1698
+ /** Stamp `Connection: close` on a response served by a draining listener so
1699
+ * the kept-alive connection retires instead of lingering as a UAF trigger.
1700
+ * Proxied responses can carry immutable headers; rewrap when needed. */
1701
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1702
+ function withDrainClose(server: any, res: Response): Response {
1703
+ if (!DRAINING_INGRESS.has(server)) return res;
1704
+ try {
1705
+ res.headers.set("connection", "close");
1706
+ return res;
1707
+ } catch {
1708
+ const headers = new Headers(res.headers);
1709
+ headers.set("connection", "close");
1710
+ return new Response(res.body, {
1711
+ status: res.status,
1712
+ statusText: res.statusText,
1713
+ headers,
1714
+ });
1715
+ }
1716
+ }
1682
1717
  /**
1683
1718
  * The live ingress tables — per-port routes and the :443 SNI cert table.
1684
1719
  *
@@ -2035,6 +2070,22 @@ function rebindHttpsListener(Bun: any): void {
2035
2070
  // upload through ingress must not be collateral damage of another
2036
2071
  // service being provisioned.
2037
2072
  old.stop(false);
2073
+ // Bun 1.3.14 landmine: a request arriving later on one of the old
2074
+ // server's kept-alive connections dispatches into freed state and can
2075
+ // segfault the daemon (see {@link DRAINING_INGRESS}). Mark it so any
2076
+ // response it still serves closes its connection, and force-close the
2077
+ // stragglers once in-flight work has had a fair window to finish.
2078
+ DRAINING_INGRESS.add(old);
2079
+ const graceTimer = setTimeout(() => {
2080
+ try {
2081
+ old.stop(true);
2082
+ } catch {
2083
+ /* already fully stopped */
2084
+ }
2085
+ }, REBIND_DRAIN_GRACE_MS);
2086
+ // Don't let the grace timer keep the process alive on shutdown.
2087
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2088
+ (graceTimer as any).unref?.();
2038
2089
  } catch (err) {
2039
2090
  // eslint-disable-next-line no-console
2040
2091
  console.warn("[ingress] failed to drain the previous https listener:", err);
@@ -2193,7 +2244,9 @@ function bindIngressServer(
2193
2244
  idleTimeout: 0,
2194
2245
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2195
2246
  fetch: (req: Request, server: any): Response | Promise<Response> =>
2196
- dispatchIngress(req, server, byHost, listenerLabel, proto),
2247
+ dispatchIngress(req, server, byHost, listenerLabel, proto).then((res) =>
2248
+ withDrainClose(server, res),
2249
+ ),
2197
2250
  websocket: {
2198
2251
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2199
2252
  async open(ws: any) {
@@ -4092,6 +4145,30 @@ function describeRequestBody(
4092
4145
  return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
4093
4146
  }
4094
4147
 
4148
+ /**
4149
+ * Marks an error thrown by the instrumented fetch as *transport-level* —
4150
+ * the connection itself failed (refused, unresolvable, reset) before any
4151
+ * HTTP reply existed. `fetch` never rejects for an HTTP status, so every
4152
+ * rejection short of an abort is transport. `Symbol.for` so a duplicated
4153
+ * SDK module instance (the bun hardlink landmine) still recognises it.
4154
+ *
4155
+ * Why it exists: `ctx.poll` waits for convergence, and right after a fork
4156
+ * restore the guest can serve a ~10 s window where a connect or a DNS
4157
+ * lookup fails once and then heals (measured 2026-08-21: a poll's first
4158
+ * fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
4159
+ * 1 while attempt 2 would have passed). During a poll, a dead connection
4160
+ * is just "not ready yet"; outside one it stays a hard error.
4161
+ */
4162
+ const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
4163
+
4164
+ function isTransportError(err: unknown): boolean {
4165
+ return (
4166
+ typeof err === "object" &&
4167
+ err !== null &&
4168
+ (err as Record<symbol, unknown>)[TRANSPORT_ERROR] === true
4169
+ );
4170
+ }
4171
+
4095
4172
  /**
4096
4173
  * Install a fetch wrapper on `globalThis` that emits HTTP events into the
4097
4174
  * active recorder. Returns a restore function. Calls outside of a running
@@ -4161,6 +4238,21 @@ function installFetchWrapper(): () => void {
4161
4238
  durationMs: Date.now() - start,
4162
4239
  error: e?.message ?? String(err),
4163
4240
  }, resv);
4241
+ // Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
4242
+ // is the caller's own signal (their AbortController or their
4243
+ // AbortSignal.timeout) — their semantics, never retried for them.
4244
+ if (
4245
+ typeof err === "object" &&
4246
+ err !== null &&
4247
+ e?.name !== "AbortError" &&
4248
+ e?.name !== "TimeoutError"
4249
+ ) {
4250
+ try {
4251
+ (err as Record<symbol, unknown>)[TRANSPORT_ERROR] = true;
4252
+ } catch {
4253
+ /* frozen error object — stays a hard error */
4254
+ }
4255
+ }
4164
4256
  throw err;
4165
4257
  }
4166
4258
  };
@@ -4412,6 +4504,7 @@ async function pollCall<T>(
4412
4504
  let value: T | undefined;
4413
4505
  let success = false;
4414
4506
  let predicateError: unknown;
4507
+ let lastTransportError: unknown;
4415
4508
 
4416
4509
  // Record all iterations normally, but keep only the newest one: a new
4417
4510
  // attempt drops the events the previous attempt emitted, so the timeline
@@ -4442,8 +4535,18 @@ async function pollCall<T>(
4442
4535
  break;
4443
4536
  }
4444
4537
  } catch (err) {
4445
- predicateError = err;
4446
- break;
4538
+ // A transport-level fetch failure (connection refused, DNS miss —
4539
+ // see TRANSPORT_ERROR) is "not ready yet", not a verdict: polls wait
4540
+ // for convergence, and services legitimately refuse connections
4541
+ // while they come up. Keep polling; the kept last attempt's http
4542
+ // event carries the error for the timeline. Anything else — an
4543
+ // assertion, a TypeError, a user abort — stays fatal on attempt 1.
4544
+ if (isTransportError(err)) {
4545
+ lastTransportError = err;
4546
+ } else {
4547
+ predicateError = err;
4548
+ break;
4549
+ }
4447
4550
  }
4448
4551
  if (Date.now() - start + intervalMs > timeoutMs) break;
4449
4552
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -4454,7 +4557,11 @@ async function pollCall<T>(
4454
4557
  ? ((predicateError as Error)?.message ?? String(predicateError))
4455
4558
  : success
4456
4559
  ? undefined
4457
- : `timed out after ${timeoutMs}ms`;
4560
+ : lastTransportError !== undefined
4561
+ ? `timed out after ${timeoutMs}ms (last attempt: ${
4562
+ (lastTransportError as Error)?.message ?? String(lastTransportError)
4563
+ })`
4564
+ : `timed out after ${timeoutMs}ms`;
4458
4565
  const seq = recordWait({
4459
4566
  description,
4460
4567
  attempts,
@@ -4474,8 +4581,12 @@ async function pollCall<T>(
4474
4581
  if (success) {
4475
4582
  return wrap(value as T, seq) as T;
4476
4583
  }
4584
+ const lastAttempt =
4585
+ lastTransportError !== undefined
4586
+ ? `; last attempt: ${(lastTransportError as Error)?.message ?? String(lastTransportError)}`
4587
+ : "";
4477
4588
  throw new Error(
4478
- `poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`,
4589
+ `poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts${lastAttempt})`,
4479
4590
  );
4480
4591
  }
4481
4592
 
@@ -5651,6 +5762,7 @@ function loadedSummary(l: ReturnType<typeof requireLoaded>) {
5651
5762
  return {
5652
5763
  environment: l.project.environment,
5653
5764
  cases: casesMetadata(l.project.tests),
5765
+ groups: groupsMetadata(l.project.tests),
5654
5766
  fakes: fakesSummary(l.project),
5655
5767
  };
5656
5768
  }
@@ -5719,6 +5831,7 @@ export function harnessMethods(state: RouteState): MethodTable {
5719
5831
  return {
5720
5832
  environment: proj.environment,
5721
5833
  cases: casesMetadata(proj.tests),
5834
+ groups: groupsMetadata(proj.tests),
5722
5835
  fakes: fakesSummary(proj),
5723
5836
  };
5724
5837
  },
@@ -5758,7 +5871,10 @@ export function harnessMethods(state: RouteState): MethodTable {
5758
5871
 
5759
5872
  envConfig: async () => requireLoaded().project.environment,
5760
5873
 
5761
- cases: async () => ({ cases: casesMetadata(requireLoaded().project.tests) }),
5874
+ cases: async () => ({
5875
+ cases: casesMetadata(requireLoaded().project.tests),
5876
+ groups: groupsMetadata(requireLoaded().project.tests),
5877
+ }),
5762
5878
 
5763
5879
  // Union of platform secret refs the loaded fakes declare (replayFake's
5764
5880
  // `secretRefs`). The control plane resolves these server-side and pushes
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { defineEnvironment } from "./index";
4
+
5
+ const newEnv = () =>
6
+ defineEnvironment({
7
+ name: "groups",
8
+ services: { web: { image: { type: "registry", reference: "nginx" } } },
9
+ });
10
+
11
+ describe("env.group", () => {
12
+ test("a member takes the group's parent, so a group is one level", () => {
13
+ const env = newEnv();
14
+ const parent = env.test("create invoice", async () => ({ id: 7 }));
15
+ const errors = env.group("Errors", { dependsOn: parent });
16
+ const member = env.test("refund too much", { group: errors }, async () => {});
17
+
18
+ expect(member.dependsOn).toBe(parent);
19
+ expect(member.group).toBe(errors);
20
+ });
21
+
22
+ test("a top-level group leaves its members roots", () => {
23
+ const env = newEnv();
24
+ const billing = env.group("Billing");
25
+ const member = env.test("create invoice", { group: billing }, async () => {});
26
+
27
+ expect(member.dependsOn).toBeUndefined();
28
+ expect(member.group!.id).toBe("billing");
29
+ });
30
+
31
+ test("the id is qualified by the parent, so a name can repeat elsewhere", () => {
32
+ const env = newEnv();
33
+ const a = env.test("create invoice", async () => {});
34
+ const b = env.test("refund", { dependsOn: a }, async () => {});
35
+
36
+ expect(env.group("Errors", { dependsOn: a }).id).toBe("create-invoice/errors");
37
+ expect(env.group("Errors", { dependsOn: b }).id).toBe("refund/errors");
38
+ expect(env.group("Errors").id).toBe("errors");
39
+ });
40
+
41
+ test("two groups with the same name and parent are refused", () => {
42
+ const env = newEnv();
43
+ const parent = env.test("create invoice", async () => {});
44
+ env.group("Errors", { dependsOn: parent });
45
+
46
+ expect(() => env.group("Errors", { dependsOn: parent })).toThrow(
47
+ /"Errors" is declared twice under "create invoice"/,
48
+ );
49
+ });
50
+
51
+ test("two top-level groups with the same name are refused", () => {
52
+ const env = newEnv();
53
+ env.group("Billing");
54
+ expect(() => env.group("Billing")).toThrow(/twice at the top level/);
55
+ });
56
+
57
+ test("a grouped test still validates its parent is in the suite", () => {
58
+ const env = newEnv();
59
+ const parent = env.test("create invoice", async () => {});
60
+ const errors = env.group("Errors", { dependsOn: parent });
61
+ const member = env.test("refund too much", { group: errors }, async () => {});
62
+
63
+ expect(() => env.project({ tests: [member] }).tests).toThrow(
64
+ /depends on "create invoice" which is not in the suite/,
65
+ );
66
+ expect(env.project({ tests: [parent, member] }).tests!.tests).toHaveLength(2);
67
+ });
68
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { casesMetadata, groupsMetadata, type CatalogueCase } from "./catalogue";
4
+
5
+ const billing = { id: "billing", name: "Billing" };
6
+ const errors = {
7
+ id: "create-invoice/errors",
8
+ name: "Errors",
9
+ dependsOn: { id: "create-invoice" },
10
+ };
11
+
12
+ const cases: CatalogueCase[] = [
13
+ { id: "create-invoice", name: "create invoice", group: billing },
14
+ { id: "list-invoices", name: "list invoices", group: billing },
15
+ { id: "refund", name: "refund", dependsOn: { id: "create-invoice" } },
16
+ {
17
+ id: "refund-too-much",
18
+ name: "refund too much",
19
+ dependsOn: { id: "create-invoice" },
20
+ group: errors,
21
+ timeoutMs: 1000,
22
+ },
23
+ ];
24
+
25
+ describe("casesMetadata", () => {
26
+ test("flattens the case refs to ids", () => {
27
+ expect(casesMetadata(cases)[3]).toEqual({
28
+ id: "refund-too-much",
29
+ name: "refund too much",
30
+ dependsOn: "create-invoice",
31
+ timeoutMs: 1000,
32
+ groupId: "create-invoice/errors",
33
+ });
34
+ });
35
+
36
+ test("a case in no group carries no group id", () => {
37
+ expect(casesMetadata(cases)[2]!.groupId).toBeUndefined();
38
+ });
39
+
40
+ test("no suite is an empty catalogue", () => {
41
+ expect(casesMetadata(undefined)).toEqual([]);
42
+ });
43
+ });
44
+
45
+ describe("groupsMetadata", () => {
46
+ test("one entry per group, where its first member appears", () => {
47
+ expect(groupsMetadata(cases)).toEqual([
48
+ { id: "billing", name: "Billing", dependsOn: undefined },
49
+ {
50
+ id: "create-invoice/errors",
51
+ name: "Errors",
52
+ dependsOn: "create-invoice",
53
+ },
54
+ ]);
55
+ });
56
+
57
+ test("a suite with no groups has none", () => {
58
+ expect(groupsMetadata([{ id: "a", name: "a" }])).toEqual([]);
59
+ expect(groupsMetadata(undefined)).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The test catalogue the control plane reads: one entry per case, plus the
3
+ * display-only groups.
4
+ *
5
+ * Ported out of `daemon.ts`. Three harness methods (`load`, `loadTests`,
6
+ * `cases`) return this, so the mapping lives in one place.
7
+ */
8
+
9
+ /** The subset of a `TestCase` the catalogue describes. Structural, so the
10
+ * mapping needs no import from the SDK entry point. */
11
+ export interface CatalogueCase {
12
+ id: string;
13
+ name: string;
14
+ dependsOn?: { id: string };
15
+ timeoutMs?: number;
16
+ group?: { id: string; name: string; dependsOn?: { id: string } };
17
+ }
18
+
19
+ export interface CaseMeta {
20
+ id: string;
21
+ name: string;
22
+ dependsOn?: string;
23
+ timeoutMs?: number;
24
+ /** The group this case is shown in, if any. Display only — a group never
25
+ * changes the DAG, which `dependsOn` still describes in full. */
26
+ groupId?: string;
27
+ }
28
+
29
+ /** A display-only test group (`env.group(...)`), for the CLI tree and the
30
+ * dashboard. `dependsOn` is the group's own level: every member of the
31
+ * group depends on that same case (or on nothing, at the top level). */
32
+ export interface GroupMeta {
33
+ id: string;
34
+ name: string;
35
+ dependsOn?: string;
36
+ }
37
+
38
+ export function casesMetadata(cases: readonly CatalogueCase[] | undefined): CaseMeta[] {
39
+ if (!cases) return [];
40
+ return cases.map((t) => ({
41
+ id: t.id,
42
+ name: t.name,
43
+ dependsOn: t.dependsOn?.id,
44
+ timeoutMs: t.timeoutMs,
45
+ groupId: t.group?.id,
46
+ }));
47
+ }
48
+
49
+ /**
50
+ * The groups the suite uses, in the order their first member appears —
51
+ * which is the order both surfaces render in.
52
+ *
53
+ * A group is a value the tests point at, not a registry, so the list is
54
+ * derived here. Members of one group hold the same group value, and its id
55
+ * is derived from its name and its parent, so keying by id collapses them
56
+ * to one entry.
57
+ */
58
+ export function groupsMetadata(cases: readonly CatalogueCase[] | undefined): GroupMeta[] {
59
+ if (!cases) return [];
60
+ const groups = new Map<string, GroupMeta>();
61
+ for (const t of cases) {
62
+ const g = t.group;
63
+ if (!g || groups.has(g.id)) continue;
64
+ groups.set(g.id, { id: g.id, name: g.name, dependsOn: g.dependsOn?.id });
65
+ }
66
+ return [...groups.values()];
67
+ }
package/src/index.ts CHANGED
@@ -1330,18 +1330,89 @@ export interface TestCase<
1330
1330
  readonly name: string;
1331
1331
  /** Parent test, if any. Single-parent for now. */
1332
1332
  readonly dependsOn?: TestCase<unknown, S, F>;
1333
+ /** The group this test is shown in, if any — set by the `group`
1334
+ * option, which also supplies `dependsOn`. Display only: a group
1335
+ * never changes how the suite runs. See {@link TestGroup}. */
1336
+ readonly group?: TestGroup<unknown, S, F>;
1333
1337
  /** Override the default per-test timeout (default 60s). */
1334
1338
  readonly timeoutMs?: number;
1335
1339
  /** @internal — the body that the in-sandbox daemon invokes. */
1336
1340
  readonly run: TestFn<T, unknown, S, F>;
1337
1341
  }
1338
1342
 
1343
+ /**
1344
+ * A named group of sibling tests, created by `env.group(...)`. A group is
1345
+ * purely informative — the CLI and the dashboard show its name above the
1346
+ * tests in it, and nothing about the run changes.
1347
+ *
1348
+ * A group holds tests at ONE level of the tree: every member shares the
1349
+ * group's own parent. That is why a member declares `group` INSTEAD OF
1350
+ * `dependsOn` — the group supplies the parent, so a group can never span
1351
+ * two levels. The descendants of a member are shown inside the group as
1352
+ * well, but they declare only their own parent, as they always did.
1353
+ *
1354
+ * `P` is the return type of the parent test, so a member's `ctx.parent`
1355
+ * is typed exactly as if it had named `dependsOn` itself.
1356
+ *
1357
+ * ```ts
1358
+ * export const billing = env.group("Billing"); // top level
1359
+ * export const createInvoice = env.test("create invoice", { group: billing }, fn);
1360
+ * // A child. It is shown inside "Billing" already, so it names its parent.
1361
+ * export const refund = env.test("refund", { dependsOn: createInvoice }, fn);
1362
+ * // Groups nest through tests: this one sits under createInvoice.
1363
+ * export const errors = env.group("Invoice errors", { dependsOn: createInvoice });
1364
+ * ```
1365
+ */
1366
+ export interface TestGroup<
1367
+ P = undefined,
1368
+ S extends ServicesMap = ServicesMap,
1369
+ F extends FakesMap = FakesMap,
1370
+ > {
1371
+ /** Stable id — `<parent-case-id>/<slug of name>`, or the bare slug at
1372
+ * top level. Derived from the name and the parent, never minted, so it
1373
+ * is the same in every run: the dashboard and the run diff key on it.
1374
+ * Qualifying by the parent is what lets the same name appear in
1375
+ * different parts of the tree. */
1376
+ readonly id: string;
1377
+ readonly name: string;
1378
+ /** The parent test every member of this group depends on. `undefined`
1379
+ * for a top-level group. */
1380
+ readonly dependsOn?: TestCase<P, S, F>;
1381
+ }
1382
+
1383
+ /** Options for {@link DefinedEnvironment.group}. */
1384
+ export interface GroupOpts<
1385
+ P = undefined,
1386
+ S extends ServicesMap = ServicesMap,
1387
+ F extends FakesMap = FakesMap,
1388
+ > {
1389
+ /** The parent every member of the group depends on. Omit it for a
1390
+ * top-level group. */
1391
+ dependsOn?: TestCase<P, S, F>;
1392
+ }
1393
+
1394
+ /** Test options that name the parent directly. `group` is `never` here,
1395
+ * so giving both options is a compile error rather than a rule the
1396
+ * suite has to check at load time. */
1339
1397
  export interface TestOpts<
1340
1398
  P = undefined,
1341
1399
  S extends ServicesMap = ServicesMap,
1342
1400
  F extends FakesMap = FakesMap,
1343
1401
  > {
1344
1402
  dependsOn?: TestCase<P, S, F>;
1403
+ group?: never;
1404
+ timeoutMs?: number;
1405
+ }
1406
+
1407
+ /** Test options that name a group. The group supplies the parent, so
1408
+ * `dependsOn` is `never` here — see {@link TestOpts}. */
1409
+ export interface GroupedTestOpts<
1410
+ P = undefined,
1411
+ S extends ServicesMap = ServicesMap,
1412
+ F extends FakesMap = FakesMap,
1413
+ > {
1414
+ group: TestGroup<P, S, F>;
1415
+ dependsOn?: never;
1345
1416
  timeoutMs?: number;
1346
1417
  }
1347
1418
 
@@ -1772,6 +1843,11 @@ export interface TypedTest<
1772
1843
  opts: TestOpts<P, S, F>,
1773
1844
  fn: TestFn<T, P, S, F>,
1774
1845
  ): TestCase<T, S, F>;
1846
+ <T = void, P = undefined>(
1847
+ name: string,
1848
+ opts: GroupedTestOpts<P, S, F>,
1849
+ fn: TestFn<T, P, S, F>,
1850
+ ): TestCase<T, S, F>;
1775
1851
  }
1776
1852
 
1777
1853
  // ──────────────────────────────────────────────────────────────────────────
@@ -1914,6 +1990,24 @@ export interface DefinedEnvironment<
1914
1990
  * `ctx.fakes.<key>` are strongly typed against the services and fakes
1915
1991
  * maps. */
1916
1992
  readonly test: TypedTest<S, F>;
1993
+ /**
1994
+ * Declare a group of sibling tests, for display only. Pass the group to
1995
+ * a test's `group` option instead of `dependsOn` — the group carries the
1996
+ * parent, so every member sits at one level of the tree.
1997
+ *
1998
+ * ```ts
1999
+ * export const billing = env.group("Billing");
2000
+ * export const errors = env.group("Errors", { dependsOn: createInvoice });
2001
+ * ```
2002
+ *
2003
+ * Two groups with the same name AND the same parent are an error:
2004
+ * export one group value and import it where you need it. The same name
2005
+ * under a different parent is fine.
2006
+ */
2007
+ group<P = undefined>(
2008
+ name: string,
2009
+ opts?: GroupOpts<P, S, F>,
2010
+ ): TestGroup<P, S, F>;
1917
2011
  /** Bundle this environment with a test suite into the project default
1918
2012
  * export. Pass tests as a plain array for the common case, or an
1919
2013
  * options bag to attach project-level `setup`. (Fakes are declared on
@@ -2023,6 +2117,35 @@ export function defineEnvironment<
2023
2117
  return tc;
2024
2118
  }) as unknown as TypedTest<S, F>;
2025
2119
 
2120
+ // Group ids are derived (`<parent-case-id>/<slug>`), never minted, so a
2121
+ // group keeps its id across runs — the dashboard and the run diff key on
2122
+ // it. Qualifying by the parent is what lets the same name be used in
2123
+ // different parts of the tree; only two groups under the SAME parent
2124
+ // collide, and that is the one case we refuse. Checking at creation time
2125
+ // (rather than in `validateSuite`) puts the error at the `env.group` call
2126
+ // that is wrong, and catches a group that no test has joined yet.
2127
+ const groupIds = new Set<string>();
2128
+ const group = <P = undefined>(
2129
+ name: string,
2130
+ opts?: GroupOpts<P, S, F>,
2131
+ ): TestGroup<P, S, F> => {
2132
+ const parent = opts?.dependsOn;
2133
+ const slug = slugify(name, "group");
2134
+ const id = parent ? `${parent.id}/${slug}` : slug;
2135
+ if (groupIds.has(id)) {
2136
+ throw new Error(
2137
+ `group ${JSON.stringify(name)} is declared twice${
2138
+ parent ? ` under "${parent.name}"` : " at the top level"
2139
+ } — export one group value and import it in both places`,
2140
+ );
2141
+ }
2142
+ groupIds.add(id);
2143
+ const g: TestGroup<P, S, F> = parent
2144
+ ? { id, name, dependsOn: parent }
2145
+ : { id, name };
2146
+ return g;
2147
+ };
2148
+
2026
2149
  function project(
2027
2150
  arg?: TestCase<unknown, S, F>[] | ProjectOpts<S, F>,
2028
2151
  ): Project<S, F> {
@@ -2056,6 +2179,7 @@ export function defineEnvironment<
2056
2179
  return {
2057
2180
  config,
2058
2181
  test,
2182
+ group,
2059
2183
  project,
2060
2184
  };
2061
2185
  }
@@ -2113,12 +2237,12 @@ function validateFakes(env: EnvironmentConfig, fakes: FakesMap): void {
2113
2237
  function buildTestCase(
2114
2238
  name: string,
2115
2239
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2116
- optsOrFn: TestOpts<any> | TestFn<unknown, any>,
2240
+ optsOrFn: AnyTestOpts | TestFn<unknown, any>,
2117
2241
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2118
2242
  maybeFn?: TestFn<unknown, any>,
2119
2243
  ): TestCase<unknown> {
2120
2244
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2121
- const [opts, fn]: [TestOpts<any>, TestFn<unknown, any>] =
2245
+ const [opts, fn]: [AnyTestOpts, TestFn<unknown, any>] =
2122
2246
  typeof optsOrFn === "function" ? [{}, optsOrFn] : [optsOrFn, maybeFn!];
2123
2247
  if (typeof fn !== "function") {
2124
2248
  throw new TypeError(
@@ -2128,12 +2252,27 @@ function buildTestCase(
2128
2252
  return {
2129
2253
  id: slugify(name),
2130
2254
  name,
2131
- dependsOn: opts.dependsOn,
2255
+ // `group` and `dependsOn` are mutually exclusive in the types, so at
2256
+ // most one of these is set. A grouped test takes the group's parent —
2257
+ // which is what keeps a group to one level of the tree, with no rule
2258
+ // for the suite to check.
2259
+ dependsOn: opts.dependsOn ?? opts.group?.dependsOn,
2260
+ group: opts.group,
2132
2261
  timeoutMs: opts.timeoutMs,
2133
2262
  run: fn,
2134
2263
  };
2135
2264
  }
2136
2265
 
2266
+ /** The two option shapes as one, for the untyped runtime implementation.
2267
+ * The public overloads keep them apart — see {@link TestOpts}. */
2268
+ interface AnyTestOpts {
2269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2270
+ dependsOn?: TestCase<any>;
2271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2272
+ group?: TestGroup<any>;
2273
+ timeoutMs?: number;
2274
+ }
2275
+
2137
2276
  function validateSuite<
2138
2277
  S extends ServicesMap,
2139
2278
  F extends FakesMap,
@@ -2162,13 +2301,15 @@ function validateSuite<
2162
2301
  return suite;
2163
2302
  }
2164
2303
 
2165
- function slugify(name: string): string {
2304
+ function slugify(name: string, kind: "test" | "group" = "test"): string {
2166
2305
  const slug = name
2167
2306
  .toLowerCase()
2168
2307
  .replace(/[^a-z0-9]+/g, "-")
2169
2308
  .replace(/^-+|-+$/g, "");
2170
2309
  if (!slug) {
2171
- throw new Error(`cannot derive id from test name ${JSON.stringify(name)}`);
2310
+ throw new Error(
2311
+ `cannot derive id from ${kind} name ${JSON.stringify(name)}`,
2312
+ );
2172
2313
  }
2173
2314
  return slug;
2174
2315
  }