@specific.dev/spectest 0.52.0 → 0.53.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
@@ -4640,6 +4636,7 @@ function loadedSummary(l) {
4640
4636
  return {
4641
4637
  environment: l.project.environment,
4642
4638
  cases: casesMetadata(l.project.tests),
4639
+ groups: groupsMetadata(l.project.tests),
4643
4640
  fakes: fakesSummary(l.project),
4644
4641
  };
4645
4642
  }
@@ -4703,6 +4700,7 @@ export function harnessMethods(state) {
4703
4700
  return {
4704
4701
  environment: proj.environment,
4705
4702
  cases: casesMetadata(proj.tests),
4703
+ groups: groupsMetadata(proj.tests),
4706
4704
  fakes: fakesSummary(proj),
4707
4705
  };
4708
4706
  },
@@ -4737,7 +4735,10 @@ export function harnessMethods(state) {
4737
4735
  return loadedSummary(requireLoaded());
4738
4736
  },
4739
4737
  envConfig: async () => requireLoaded().project.environment,
4740
- cases: async () => ({ cases: casesMetadata(requireLoaded().project.tests) }),
4738
+ cases: async () => ({
4739
+ cases: casesMetadata(requireLoaded().project.tests),
4740
+ groups: groupsMetadata(requireLoaded().project.tests),
4741
+ }),
4741
4742
  // Union of platform secret refs the loaded fakes declare (replayFake's
4742
4743
  // `secretRefs`). The control plane resolves these server-side and pushes
4743
4744
  // 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.53.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) {
@@ -5651,6 +5649,7 @@ function loadedSummary(l: ReturnType<typeof requireLoaded>) {
5651
5649
  return {
5652
5650
  environment: l.project.environment,
5653
5651
  cases: casesMetadata(l.project.tests),
5652
+ groups: groupsMetadata(l.project.tests),
5654
5653
  fakes: fakesSummary(l.project),
5655
5654
  };
5656
5655
  }
@@ -5719,6 +5718,7 @@ export function harnessMethods(state: RouteState): MethodTable {
5719
5718
  return {
5720
5719
  environment: proj.environment,
5721
5720
  cases: casesMetadata(proj.tests),
5721
+ groups: groupsMetadata(proj.tests),
5722
5722
  fakes: fakesSummary(proj),
5723
5723
  };
5724
5724
  },
@@ -5758,7 +5758,10 @@ export function harnessMethods(state: RouteState): MethodTable {
5758
5758
 
5759
5759
  envConfig: async () => requireLoaded().project.environment,
5760
5760
 
5761
- cases: async () => ({ cases: casesMetadata(requireLoaded().project.tests) }),
5761
+ cases: async () => ({
5762
+ cases: casesMetadata(requireLoaded().project.tests),
5763
+ groups: groupsMetadata(requireLoaded().project.tests),
5764
+ }),
5762
5765
 
5763
5766
  // Union of platform secret refs the loaded fakes declare (replayFake's
5764
5767
  // `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
  }