@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 +21 -20
- package/dist/harness/catalogue.d.ts +52 -0
- package/dist/harness/catalogue.js +39 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +30 -3
- package/package.json +1 -1
- package/src/daemon.ts +34 -31
- package/src/groups.test.ts +68 -0
- package/src/harness/catalogue.test.ts +61 -0
- package/src/harness/catalogue.ts +67 -0
- package/src/index.ts +146 -5
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
*
|
|
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 () => ({
|
|
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
|
|
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
|
|
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
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
258
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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 () => ({
|
|
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:
|
|
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]: [
|
|
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
|
|
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(
|
|
2310
|
+
throw new Error(
|
|
2311
|
+
`cannot derive id from ${kind} name ${JSON.stringify(name)}`,
|
|
2312
|
+
);
|
|
2172
2313
|
}
|
|
2173
2314
|
return slug;
|
|
2174
2315
|
}
|