@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 +132 -25
- 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 +152 -36
- 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
|
|
@@ -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
|
-
|
|
3626
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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 () => ({
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
4446
|
-
|
|
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
|
-
:
|
|
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 () => ({
|
|
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:
|
|
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
|
}
|