@specific.dev/spectest 0.92.0 → 0.92.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { generateHostCert as mintHostCert } from "./harness/certificate.js";
1
2
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
2
3
  import { waitForAll } from "./harness/concurrency.js";
3
4
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
@@ -39,7 +40,7 @@ import { isDesktopApp } from "./desktop.js";
39
40
  // exact-then-longest-suffix rule and the one-label certificate rule are
40
41
  // each easy to restate subtly differently.
41
42
  import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
42
- import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
43
+ import { startServices } from "./harness/service-graph.js";
43
44
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
44
45
  import { summarizeBuildKit } from "./harness/buildkit-progress.js";
45
46
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
@@ -1453,29 +1454,6 @@ async function withContainerPostMortem(name, err) {
1453
1454
  return err;
1454
1455
  }
1455
1456
  }
1456
- /** Validate the `dependsOn` graph and return the name→service map used to
1457
- * walk it. Rules live in `harness/service-graph.ts`. */
1458
- function validateServiceGraph(services) {
1459
- return validateGraph(services);
1460
- }
1461
- async function startServices(services, startOne) {
1462
- const byName = validateServiceGraph(services);
1463
- const started = new Map();
1464
- const start = (svc) => {
1465
- const existing = started.get(svc.name);
1466
- if (existing)
1467
- return existing;
1468
- const p = (async () => {
1469
- await Promise.all((svc.dependsOn ?? [])
1470
- .filter((d) => byName.has(d))
1471
- .map((d) => start(byName.get(d))));
1472
- await startOne(svc);
1473
- })();
1474
- started.set(svc.name, p);
1475
- return p;
1476
- };
1477
- await Promise.all(services.map(start));
1478
- }
1479
1457
  // ────────────────────────────────────────────────────────────────────────
1480
1458
  // Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
1481
1459
  //
@@ -1625,67 +1603,6 @@ function buildIngress(project) {
1625
1603
  });
1626
1604
  }
1627
1605
  }
1628
- /**
1629
- * Generate a leaf cert + key for one ingress route (fake or service
1630
- * proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
1631
- * hostname the route answers to, so a client connecting with TLS
1632
- * verifies cleanly regardless of which hostname it used. Shells out
1633
- * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
1634
- * bookworm ships 3.0.x).
1635
- *
1636
- * `label` is a short tag baked into the cert Subject CN and the temp
1637
- * file names — only used for diagnostics, not for TLS verification.
1638
- */
1639
- async function generateHostCert(label, hostnames) {
1640
- const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
1641
- const keyPath = path.join("/tmp", `${id}.key`);
1642
- const crtPath = path.join("/tmp", `${id}.crt`);
1643
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
1644
- const args = [
1645
- "req",
1646
- "-newkey",
1647
- "rsa:2048",
1648
- "-nodes",
1649
- "-keyout",
1650
- keyPath,
1651
- "-out",
1652
- crtPath,
1653
- "-x509",
1654
- "-CA",
1655
- CA_PATH,
1656
- "-CAkey",
1657
- CA_KEY_PATH,
1658
- "-days",
1659
- "3650",
1660
- "-subj",
1661
- `/CN=spectest-${label}`,
1662
- "-addext",
1663
- `subjectAltName=${sans}`,
1664
- "-addext",
1665
- "basicConstraints=CA:FALSE",
1666
- "-addext",
1667
- "extendedKeyUsage=serverAuth",
1668
- "-addext",
1669
- "keyUsage=digitalSignature,keyEncipherment",
1670
- ];
1671
- const r = await shx("openssl", args, 30_000);
1672
- if (r.code !== 0) {
1673
- throw new Error(`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`);
1674
- }
1675
- try {
1676
- const [cert, key] = await Promise.all([
1677
- fs.readFile(crtPath, "utf8"),
1678
- fs.readFile(keyPath, "utf8"),
1679
- ]);
1680
- return { cert, key };
1681
- }
1682
- finally {
1683
- await Promise.all([
1684
- fs.unlink(keyPath).catch(() => { }),
1685
- fs.unlink(crtPath).catch(() => { }),
1686
- ]);
1687
- }
1688
- }
1689
1606
  /** Resolve the spectest-net bridge gateway IP — the address containers
1690
1607
  * use to reach the VM host. Asks dockerd via the docker CLI; cached for
1691
1608
  * the daemon's life because the network is recreated only on reload. */
@@ -1719,7 +1636,10 @@ async function bridgeGatewayIp() {
1719
1636
  * that don't pass through as primitives.
1720
1637
  */
1721
1638
  const NATIVE_FETCH = globalThis.fetch.bind(globalThis);
1722
- /** Hop-by-hop headers per RFC 7230 §6.1 — never forwarded by a proxy. */
1639
+ /** Mint ingress and container certificates with the environment CA. */
1640
+ function generateHostCert(label, hostnames) {
1641
+ return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
1642
+ }
1723
1643
  async function startIngress() {
1724
1644
  const hasIngress = FAKES.size > 0 ||
1725
1645
  LOWERED.proxies.length > 0 ||
@@ -2942,10 +2862,14 @@ async function bootstrap(prepared) {
2942
2862
  // hook's — into the boot log, so GET /boot-log can explain a slow or
2943
2863
  // finished boot. Restored in the finally at the end.
2944
2864
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2865
+ // One dispatcher for the whole bootstrap: service hooks run concurrently.
2866
+ // Only each hook's async scope opts into wrapped fetch responses.
2867
+ const restoreFetch = installFetchWrapper();
2945
2868
  try {
2946
2869
  return await bootstrapInner(prepared);
2947
2870
  }
2948
2871
  finally {
2872
+ restoreFetch();
2949
2873
  restoreBootLog();
2950
2874
  }
2951
2875
  }
@@ -3111,22 +3035,28 @@ async function bootstrapInner(prepared, identity) {
3111
3035
  await waitForAll([...builds.map(task => prep.get(task.name)), (async () => {
3112
3036
  if (svc.setup) {
3113
3037
  progressService(svc.name, { status: "probing", detail: "running setup" });
3114
- const helpers = await ensureHelpers(svc.name, svc);
3038
+ const fetchScope = createInstrumentationScope(false);
3115
3039
  try {
3116
- await svc.setup({
3117
- name: svc.name,
3118
- helpers,
3119
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3120
- ...{ [COMPONENT_RUNTIME]: {
3121
- images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
3122
- gateway: await bridgeGatewayIp(),
3123
- caBundle: await ensureCaBundle(),
3124
- } },
3040
+ await runInstrumented(fetchScope, async () => {
3041
+ const helpers = await ensureHelpers(svc.name, svc);
3042
+ await svc.setup({
3043
+ name: svc.name,
3044
+ helpers,
3045
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3046
+ ...{ [COMPONENT_RUNTIME]: {
3047
+ images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
3048
+ gateway: await bridgeGatewayIp(),
3049
+ caBundle: await ensureCaBundle(),
3050
+ } },
3051
+ });
3125
3052
  });
3126
3053
  }
3127
3054
  catch (err) {
3128
3055
  throw await withContainerPostMortem(svc.name, err);
3129
3056
  }
3057
+ finally {
3058
+ fetchScope.active = false;
3059
+ }
3130
3060
  }
3131
3061
  })()]);
3132
3062
  for (const task of builds)
@@ -4013,7 +3943,7 @@ async function spectestContext(scope = {}) {
4013
3943
  readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
4014
3944
  exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
4015
3945
  // Read `globalThis.fetch` at call time: the wrapper is installed for
4016
- // the duration of a test / eval / project setup, so a context built
3946
+ // the duration of bootstrap / a test / eval / project setup, so a context built
4017
3947
  // before it lands still gets the wrapped one.
4018
3948
  fetch: ((input, init) => globalThis.fetch(input, init)),
4019
3949
  svc: handles,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Generate a leaf cert + key for one ingress route (fake or service
3
+ * proxy), signed by the in-VM root CA. SANs cover every
4
+ * hostname the route answers to, so a client connecting with TLS
5
+ * verifies cleanly regardless of which hostname it used. Shells out
6
+ * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
7
+ * bookworm ships 3.0.x).
8
+ *
9
+ * `label` is a diagnostic tag baked into the cert Subject CN and the temp
10
+ * file names — only used for diagnostics, not for TLS verification.
11
+ */
12
+ export declare function generateHostCert(label: string, hostnames: string[], caPath: string, caKeyPath: string): Promise<{
13
+ cert: string;
14
+ key: string;
15
+ }>;
@@ -0,0 +1,67 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ /**
8
+ * Generate a leaf cert + key for one ingress route (fake or service
9
+ * proxy), signed by the in-VM root CA. SANs cover every
10
+ * hostname the route answers to, so a client connecting with TLS
11
+ * verifies cleanly regardless of which hostname it used. Shells out
12
+ * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
13
+ * bookworm ships 3.0.x).
14
+ *
15
+ * `label` is a diagnostic tag baked into the cert Subject CN and the temp
16
+ * file names — only used for diagnostics, not for TLS verification.
17
+ */
18
+ export async function generateHostCert(label, hostnames, caPath, caKeyPath) {
19
+ // CN is diagnostic only; SANs retain the full hostnames. Bound the label
20
+ // for both OpenSSL's 64-byte CN limit and filesystem component limits.
21
+ const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
22
+ const id = `${commonName}-${randomUUID()}`;
23
+ const keyPath = path.join("/tmp", `${id}.key`);
24
+ const crtPath = path.join("/tmp", `${id}.crt`);
25
+ const sans = hostnames.map((h) => `DNS:${h}`).join(",");
26
+ const args = [
27
+ "req",
28
+ "-newkey",
29
+ "rsa:2048",
30
+ "-nodes",
31
+ "-keyout",
32
+ keyPath,
33
+ "-out",
34
+ crtPath,
35
+ "-x509",
36
+ "-CA",
37
+ caPath,
38
+ "-CAkey",
39
+ caKeyPath,
40
+ "-days",
41
+ "3650",
42
+ "-subj",
43
+ `/CN=${commonName}`,
44
+ "-addext",
45
+ `subjectAltName=${sans}`,
46
+ "-addext",
47
+ "basicConstraints=CA:FALSE",
48
+ "-addext",
49
+ "extendedKeyUsage=serverAuth",
50
+ "-addext",
51
+ "keyUsage=digitalSignature,keyEncipherment",
52
+ ];
53
+ try {
54
+ await execFileAsync("openssl", args, { timeout: 30_000 });
55
+ const [cert, key] = await Promise.all([
56
+ fs.readFile(crtPath, "utf8"),
57
+ fs.readFile(keyPath, "utf8"),
58
+ ]);
59
+ return { cert, key };
60
+ }
61
+ finally {
62
+ await Promise.all([
63
+ fs.unlink(keyPath).catch(() => { }),
64
+ fs.unlink(crtPath).catch(() => { }),
65
+ ]);
66
+ }
67
+ }
@@ -27,3 +27,6 @@ export declare function validateServiceGraph<T extends GraphNode>(services: read
27
27
  * graph is walkable at all.
28
28
  */
29
29
  export declare function topologicalOrder<T extends GraphNode>(services: readonly T[]): T[];
30
+ /** Start each service after its dependencies finish setup. Drain all branches
31
+ * before reporting failure so teardown cannot race containers or hooks. */
32
+ export declare function startServices<T extends GraphNode>(services: readonly T[], startOne: (svc: T) => Promise<void>): Promise<void>;
@@ -1,12 +1,4 @@
1
- /**
2
- * The `dependsOn` graph: validation, and the traversal order bring-up
3
- * uses.
4
- *
5
- * Ported out of `daemon.ts`. Validation runs before anything starts, so
6
- * the DAG runner can assume a clean graph — an unknown dependency or a
7
- * cycle must be a clear error at load, not a container that never becomes
8
- * ready and eventually times out with no explanation.
9
- */
1
+ import { waitForAll } from "./concurrency.js";
10
2
  /**
11
3
  * Check the graph and return the name→node map used to walk it.
12
4
  *
@@ -90,3 +82,21 @@ export function topologicalOrder(services) {
90
82
  visit(s.name);
91
83
  return out;
92
84
  }
85
+ /** Start each service after its dependencies finish setup. Drain all branches
86
+ * before reporting failure so teardown cannot race containers or hooks. */
87
+ export async function startServices(services, startOne) {
88
+ const byName = validateServiceGraph(services);
89
+ const started = new Map();
90
+ const start = (svc) => {
91
+ const existing = started.get(svc.name);
92
+ if (existing)
93
+ return existing;
94
+ const pending = (async () => {
95
+ await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name))));
96
+ await startOne(svc);
97
+ })();
98
+ started.set(svc.name, pending);
99
+ return pending;
100
+ };
101
+ await waitForAll(services.map(start));
102
+ }
package/dist/index.d.ts CHANGED
@@ -590,11 +590,10 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
590
590
  * the typecheck on any service declaring a hook, while the same literal
591
591
  * written inline passed. */
592
592
  export type ServicesMap = Record<string, ServiceDefinition<any>>;
593
- /** Awaited return type of a service's `helpers` factory, or `never` if
594
- * the service doesn't ship one. */
595
- type HelpersOf<D> = D extends {
596
- helpers: (...args: any) => infer R;
597
- } ? Awaited<R> : never;
593
+ /** Awaited return type of a service's helpers factory. Check key presence
594
+ * before removing optionality: annotations keep `helpers?`, while plain
595
+ * service literals with no factory must stay out of ctx.svc. */
596
+ type HelpersOf<D> = "helpers" extends keyof D ? [NonNullable<D["helpers"]>] extends [never] ? never : NonNullable<D["helpers"]> extends (...args: any[]) => infer R ? Awaited<R> : never : never;
598
597
  /**
599
598
  * Per-service handles derived from a concrete services map. Only
600
599
  * services that ship a `helpers` factory appear here; `ctx.svc.<name>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.92.0",
3
+ "version": "0.92.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { generateHostCert as mintHostCert } from "./harness/certificate.js";
1
2
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
2
3
  import { waitForAll } from "./harness/concurrency.js";
3
4
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
@@ -89,7 +90,7 @@ import {
89
90
  serviceDockerignore as composeServiceDockerignore,
90
91
  unionDockerignore,
91
92
  } from "./harness/build-context.js";
92
- import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
93
+ import { startServices } from "./harness/service-graph.js";
93
94
  import {
94
95
  casesMetadata as catalogueCases,
95
96
  groupsMetadata as catalogueGroups,
@@ -1859,35 +1860,6 @@ async function withContainerPostMortem(name: string, err: unknown): Promise<unkn
1859
1860
  }
1860
1861
  }
1861
1862
 
1862
- /** Validate the `dependsOn` graph and return the name→service map used to
1863
- * walk it. Rules live in `harness/service-graph.ts`. */
1864
- function validateServiceGraph(services: NamedService[]): Map<string, NamedService> {
1865
- return validateGraph(services);
1866
- }
1867
-
1868
- async function startServices(
1869
- services: NamedService[],
1870
- startOne: (svc: NamedService) => Promise<void>,
1871
- ): Promise<void> {
1872
- const byName = validateServiceGraph(services);
1873
- const started = new Map<string, Promise<void>>();
1874
- const start = (svc: NamedService): Promise<void> => {
1875
- const existing = started.get(svc.name);
1876
- if (existing) return existing;
1877
- const p = (async () => {
1878
- await Promise.all(
1879
- (svc.dependsOn ?? [])
1880
- .filter((d) => byName.has(d))
1881
- .map((d) => start(byName.get(d)!)),
1882
- );
1883
- await startOne(svc);
1884
- })();
1885
- started.set(svc.name, p);
1886
- return p;
1887
- };
1888
- await Promise.all(services.map(start));
1889
- }
1890
-
1891
1863
  // ────────────────────────────────────────────────────────────────────────
1892
1864
  // Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
1893
1865
  //
@@ -2057,72 +2029,6 @@ function buildIngress(project: Project): void {
2057
2029
  }
2058
2030
  }
2059
2031
 
2060
- /**
2061
- * Generate a leaf cert + key for one ingress route (fake or service
2062
- * proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
2063
- * hostname the route answers to, so a client connecting with TLS
2064
- * verifies cleanly regardless of which hostname it used. Shells out
2065
- * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
2066
- * bookworm ships 3.0.x).
2067
- *
2068
- * `label` is a short tag baked into the cert Subject CN and the temp
2069
- * file names — only used for diagnostics, not for TLS verification.
2070
- */
2071
- async function generateHostCert(
2072
- label: string,
2073
- hostnames: string[],
2074
- ): Promise<{ cert: string; key: string }> {
2075
- const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
2076
- const keyPath = path.join("/tmp", `${id}.key`);
2077
- const crtPath = path.join("/tmp", `${id}.crt`);
2078
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
2079
- const args = [
2080
- "req",
2081
- "-newkey",
2082
- "rsa:2048",
2083
- "-nodes",
2084
- "-keyout",
2085
- keyPath,
2086
- "-out",
2087
- crtPath,
2088
- "-x509",
2089
- "-CA",
2090
- CA_PATH,
2091
- "-CAkey",
2092
- CA_KEY_PATH,
2093
- "-days",
2094
- "3650",
2095
- "-subj",
2096
- `/CN=spectest-${label}`,
2097
- "-addext",
2098
- `subjectAltName=${sans}`,
2099
- "-addext",
2100
- "basicConstraints=CA:FALSE",
2101
- "-addext",
2102
- "extendedKeyUsage=serverAuth",
2103
- "-addext",
2104
- "keyUsage=digitalSignature,keyEncipherment",
2105
- ];
2106
- const r = await shx("openssl", args, 30_000);
2107
- if (r.code !== 0) {
2108
- throw new Error(
2109
- `openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`,
2110
- );
2111
- }
2112
- try {
2113
- const [cert, key] = await Promise.all([
2114
- fs.readFile(crtPath, "utf8"),
2115
- fs.readFile(keyPath, "utf8"),
2116
- ]);
2117
- return { cert, key };
2118
- } finally {
2119
- await Promise.all([
2120
- fs.unlink(keyPath).catch(() => {}),
2121
- fs.unlink(crtPath).catch(() => {}),
2122
- ]);
2123
- }
2124
- }
2125
-
2126
2032
  /** Resolve the spectest-net bridge gateway IP — the address containers
2127
2033
  * use to reach the VM host. Asks dockerd via the docker CLI; cached for
2128
2034
  * the daemon's life because the network is recreated only on reload. */
@@ -2165,7 +2071,11 @@ type Route = IngressRoute<FakeRuntime>;
2165
2071
  */
2166
2072
  const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
2167
2073
 
2168
- /** Hop-by-hop headers per RFC 7230 §6.1 — never forwarded by a proxy. */
2074
+ /** Mint ingress and container certificates with the environment CA. */
2075
+ function generateHostCert(label: string, hostnames: string[]): Promise<{ cert: string; key: string }> {
2076
+ return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
2077
+ }
2078
+
2169
2079
  async function startIngress(): Promise<void> {
2170
2080
  const hasIngress =
2171
2081
  FAKES.size > 0 ||
@@ -3519,9 +3429,13 @@ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
3519
3429
  // hook's — into the boot log, so GET /boot-log can explain a slow or
3520
3430
  // finished boot. Restored in the finally at the end.
3521
3431
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3432
+ // One dispatcher for the whole bootstrap: service hooks run concurrently.
3433
+ // Only each hook's async scope opts into wrapped fetch responses.
3434
+ const restoreFetch = installFetchWrapper();
3522
3435
  try {
3523
3436
  return await bootstrapInner(prepared);
3524
3437
  } finally {
3438
+ restoreFetch();
3525
3439
  restoreBootLog();
3526
3440
  }
3527
3441
  }
@@ -3695,20 +3609,25 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3695
3609
  await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
3696
3610
  if (svc.setup) {
3697
3611
  progressService(svc.name, { status: "probing", detail: "running setup" });
3698
- const helpers = await ensureHelpers(svc.name, svc);
3612
+ const fetchScope = createInstrumentationScope(false);
3699
3613
  try {
3700
- await svc.setup({
3701
- name: svc.name,
3702
- helpers,
3703
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3704
- ...{ [COMPONENT_RUNTIME]: {
3705
- images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
3706
- gateway: await bridgeGatewayIp(),
3707
- caBundle: await ensureCaBundle(),
3708
- } },
3614
+ await runInstrumented(fetchScope, async () => {
3615
+ const helpers = await ensureHelpers(svc.name, svc);
3616
+ await svc.setup!({
3617
+ name: svc.name,
3618
+ helpers,
3619
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3620
+ ...{ [COMPONENT_RUNTIME]: {
3621
+ images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
3622
+ gateway: await bridgeGatewayIp(),
3623
+ caBundle: await ensureCaBundle(),
3624
+ } },
3625
+ });
3709
3626
  });
3710
3627
  } catch (err) {
3711
3628
  throw await withContainerPostMortem(svc.name, err);
3629
+ } finally {
3630
+ fetchScope.active = false;
3712
3631
  }
3713
3632
  }
3714
3633
  })()]);
@@ -4857,7 +4776,7 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
4857
4776
  exec: (service, command, opts) =>
4858
4777
  componentExec(service, command, opts, execTimeoutMs),
4859
4778
  // Read `globalThis.fetch` at call time: the wrapper is installed for
4860
- // the duration of a test / eval / project setup, so a context built
4779
+ // the duration of bootstrap / a test / eval / project setup, so a context built
4861
4780
  // before it lands still gets the wrapped one.
4862
4781
  fetch: ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
4863
4782
  globalThis.fetch(input, init)) as unknown as SpectestContext["fetch"],
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+ import { X509Certificate } from "node:crypto";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { generateHostCert } from "./certificate.js";
7
+
8
+ test("long ingress names retain their full SAN while the diagnostic CN stays bounded", async () => {
9
+ const dir = await mkdtemp(join(tmpdir(), "spectest-leaf-"));
10
+ const ca = join(dir, "ca.crt");
11
+ const key = join(dir, "ca.key");
12
+ try {
13
+ const root = Bun.spawnSync(["openssl", "req", "-x509", "-newkey", "rsa:2048",
14
+ "-nodes", "-days", "1", "-subj", "/CN=test-ca", "-keyout", key, "-out", ca]);
15
+ expect(root.exitCode, root.stderr.toString()).toBe(0);
16
+ // Long enough to exceed both the former CN and temporary filename limits.
17
+ const host = `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`;
18
+ const { cert, key: leafKey } = await generateHostCert(host, [host, "*.example.test"], ca, key);
19
+ const leaf = new X509Certificate(cert);
20
+ expect(leaf.subject.length).toBeLessThanOrEqual(67); // "CN=" + 64 bytes
21
+ expect(leaf.checkHost(host)).toBe(host);
22
+ expect(leaf.checkHost("api.example.test")).toBe("*.example.test");
23
+ expect(leafKey).toContain("PRIVATE KEY");
24
+ const file = join(dir, "leaf.crt");
25
+ await writeFile(file, cert);
26
+ const verified = Bun.spawnSync(["openssl", "verify", "-CAfile", ca, "-verify_hostname", host, file]);
27
+ expect(verified.exitCode, verified.stderr.toString()).toBe(0);
28
+ } finally {
29
+ await rm(dir, { recursive: true, force: true });
30
+ }
31
+ }, 10_000);
@@ -0,0 +1,73 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ /**
10
+ * Generate a leaf cert + key for one ingress route (fake or service
11
+ * proxy), signed by the in-VM root CA. SANs cover every
12
+ * hostname the route answers to, so a client connecting with TLS
13
+ * verifies cleanly regardless of which hostname it used. Shells out
14
+ * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
15
+ * bookworm ships 3.0.x).
16
+ *
17
+ * `label` is a diagnostic tag baked into the cert Subject CN and the temp
18
+ * file names — only used for diagnostics, not for TLS verification.
19
+ */
20
+ export async function generateHostCert(
21
+ label: string,
22
+ hostnames: string[],
23
+ caPath: string,
24
+ caKeyPath: string,
25
+ ): Promise<{ cert: string; key: string }> {
26
+ // CN is diagnostic only; SANs retain the full hostnames. Bound the label
27
+ // for both OpenSSL's 64-byte CN limit and filesystem component limits.
28
+ const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
29
+ const id = `${commonName}-${randomUUID()}`;
30
+ const keyPath = path.join("/tmp", `${id}.key`);
31
+ const crtPath = path.join("/tmp", `${id}.crt`);
32
+ const sans = hostnames.map((h) => `DNS:${h}`).join(",");
33
+ const args = [
34
+ "req",
35
+ "-newkey",
36
+ "rsa:2048",
37
+ "-nodes",
38
+ "-keyout",
39
+ keyPath,
40
+ "-out",
41
+ crtPath,
42
+ "-x509",
43
+ "-CA",
44
+ caPath,
45
+ "-CAkey",
46
+ caKeyPath,
47
+ "-days",
48
+ "3650",
49
+ "-subj",
50
+ `/CN=${commonName}`,
51
+ "-addext",
52
+ `subjectAltName=${sans}`,
53
+ "-addext",
54
+ "basicConstraints=CA:FALSE",
55
+ "-addext",
56
+ "extendedKeyUsage=serverAuth",
57
+ "-addext",
58
+ "keyUsage=digitalSignature,keyEncipherment",
59
+ ];
60
+ try {
61
+ await execFileAsync("openssl", args, { timeout: 30_000 });
62
+ const [cert, key] = await Promise.all([
63
+ fs.readFile(crtPath, "utf8"),
64
+ fs.readFile(keyPath, "utf8"),
65
+ ]);
66
+ return { cert, key };
67
+ } finally {
68
+ await Promise.all([
69
+ fs.unlink(keyPath).catch(() => {}),
70
+ fs.unlink(crtPath).catch(() => {}),
71
+ ]);
72
+ }
73
+ }
@@ -50,6 +50,34 @@ function run(command: string, args: string[], timeout: number): Promise<{ code:
50
50
  const opts = () => ({ appDir, file, projectEntry, typecheckDir, run });
51
51
  const query = 'const rows = await ctx.svc.supabase.sql<{ analyzed: boolean }>`SELECT true AS analyzed`;';
52
52
 
53
+ test("annotated services keep helper types while literals without helpers stay absent", async () => {
54
+ await fs.writeFile(projectEntry, `
55
+ import { defineEnvironment, type ServiceDefinition } from "@specific.dev/spectest";
56
+ interface Helpers { value(): number }
57
+ const annotated: ServiceDefinition<Helpers> = {
58
+ image: "example:latest", helpers: async () => ({ value: () => 42 }),
59
+ };
60
+ const checked = { image: "example:latest", helpers: () => ({ value: () => 42 }) } satisfies ServiceDefinition<Helpers>;
61
+ const env = defineEnvironment({ name: "helpers", services: {
62
+ annotated, checked, plain: { image: "example:latest" },
63
+ absent: { image: "example:latest", helpers: undefined },
64
+ } });
65
+ export default env.project([]);
66
+ `);
67
+ await fs.writeFile(file, `
68
+ const a: number = ctx.svc.annotated.value();
69
+ const b: number = ctx.svc.checked.value();
70
+ // @ts-expect-error plain services have no handle
71
+ ctx.svc.plain;
72
+ // @ts-expect-error explicitly absent helpers have no handle either
73
+ ctx.svc.absent;
74
+ // @ts-expect-error helper return type must not widen to any
75
+ const wrong: string = ctx.svc.annotated.value();
76
+ export default a + b;
77
+ `);
78
+ expect(await checkEval(opts())).toMatchObject({ status: "ok", errors: [], totalErrors: 0 });
79
+ }, 30_000);
80
+
53
81
  test("eval receives the default-exported project's service/fake types and globals", async () => {
54
82
  await fs.writeFile(file, `${query}
55
83
  const ready: boolean = rows[0].analyzed.unwrap();
@@ -3,6 +3,7 @@ import { expect as assertValue } from "../index.js";
3
3
  import { readRaw, readTag, type SpectestFetch, type WrappedResponse } from "../inspect.js";
4
4
  import { recordFake, startRecording, stopRecording, type HttpEvent } from "../recorder.js";
5
5
  import { installFetchWrapper, isTransportError } from "./fetch.js";
6
+ import { startServices } from "./service-graph.js";
6
7
  import {
7
8
  bindInstrumentationScope,
8
9
  createInstrumentationScope,
@@ -356,4 +357,31 @@ describe("fetch execution scopes", () => {
356
357
  setup.active = false;
357
358
  }
358
359
  });
360
+
361
+ test("parallel service setup scopes keep wrapping after a sibling fails", async () => {
362
+ const gate = deferred();
363
+ const slowStarted = deferred();
364
+ const setup = startServices([{ name: "bad" }, { name: "slow" }], async ({ name }) => {
365
+ const hookScope = createInstrumentationScope(false);
366
+ try {
367
+ await runInstrumented(hookScope, async () => {
368
+ if (name === "bad") { await slowStarted.promise; throw new Error("failed hook"); }
369
+ slowStarted.resolve();
370
+ await gate.promise;
371
+ const res = await fetch(url("/service-setup")) as unknown as WrappedResponse;
372
+ expect(res.status.unwrap()).toBe(200);
373
+ expect((await res.json<{ value: number }>()).value.unwrap()).toBe(42);
374
+ await runUninstrumented(async () => {
375
+ expect((await fetch(url("/fake"))).status).toBe(200);
376
+ });
377
+ });
378
+ } finally { hookScope.active = false; }
379
+ });
380
+ const outcome = setup.catch(error => error);
381
+ await slowStarted.promise;
382
+ expect((await fetch(url("/background"))).status).toBe(200);
383
+ gate.resolve();
384
+ expect((await outcome).message).toBe("failed hook");
385
+ expect(stopRecording()).toEqual([]);
386
+ });
359
387
  });
@@ -1,9 +1,39 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { topologicalOrder, validateServiceGraph } from "./service-graph";
3
+ import { startServices, topologicalOrder, validateServiceGraph } from "./service-graph";
4
4
 
5
5
  const svc = (name: string, dependsOn?: string[]) => ({ name, dependsOn });
6
6
 
7
+ test("bootstrap waits for sibling hooks after a failure and never starts failed dependents", async () => {
8
+ let release!: () => void;
9
+ const gate = new Promise<void>(resolve => { release = resolve; });
10
+ const started: string[] = [];
11
+ let completed = false;
12
+ let settled = false;
13
+ const failed = new Error("setup failed");
14
+ const result = startServices([svc("bad"), svc("slow"), svc("child", ["bad"])], async ({ name }) => {
15
+ started.push(name);
16
+ if (name === "bad") throw failed;
17
+ if (name === "slow") { await gate; completed = true; }
18
+ }).then(() => { settled = true; return undefined; }, error => { settled = true; return error; });
19
+ await new Promise(resolve => setTimeout(resolve, 0));
20
+ expect(started).toEqual(["bad", "slow"]);
21
+ expect(settled).toBe(false);
22
+ release();
23
+ const error = await result;
24
+ expect(completed).toBe(true);
25
+ expect(error instanceof AggregateError ? error.errors : [error]).toContain(failed);
26
+ });
27
+
28
+ test("shared dependencies complete setup once before either dependent starts", async () => {
29
+ const started: string[] = [];
30
+ await startServices([svc("a", ["db"]), svc("b", ["db"]), svc("db")], async ({ name }) => {
31
+ await Promise.resolve();
32
+ started.push(name);
33
+ });
34
+ expect(started).toEqual(["db", "a", "b"]);
35
+ });
36
+
7
37
  describe("validateServiceGraph", () => {
8
38
  test("returns a name→service map for a clean graph", () => {
9
39
  const map = validateServiceGraph([svc("db"), svc("api", ["db"])]);
@@ -1,3 +1,5 @@
1
+ import { waitForAll } from "./concurrency.js";
2
+
1
3
  /**
2
4
  * The `dependsOn` graph: validation, and the traversal order bring-up
3
5
  * uses.
@@ -95,3 +97,24 @@ export function topologicalOrder<T extends GraphNode>(services: readonly T[]): T
95
97
  for (const s of services) visit(s.name);
96
98
  return out;
97
99
  }
100
+
101
+ /** Start each service after its dependencies finish setup. Drain all branches
102
+ * before reporting failure so teardown cannot race containers or hooks. */
103
+ export async function startServices<T extends GraphNode>(
104
+ services: readonly T[],
105
+ startOne: (svc: T) => Promise<void>,
106
+ ): Promise<void> {
107
+ const byName = validateServiceGraph(services);
108
+ const started = new Map<string, Promise<void>>();
109
+ const start = (svc: T): Promise<void> => {
110
+ const existing = started.get(svc.name);
111
+ if (existing) return existing;
112
+ const pending = (async () => {
113
+ await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name)!)));
114
+ await startOne(svc);
115
+ })();
116
+ started.set(svc.name, pending);
117
+ return pending;
118
+ };
119
+ await waitForAll(services.map(start));
120
+ }
package/src/index.ts CHANGED
@@ -796,13 +796,16 @@ export interface ServiceDefinition<
796
796
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
797
797
  export type ServicesMap = Record<string, ServiceDefinition<any>>;
798
798
 
799
- /** Awaited return type of a service's `helpers` factory, or `never` if
800
- * the service doesn't ship one. */
801
- type HelpersOf<D> = D extends {
802
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
803
- helpers: (...args: any) => infer R;
804
- }
805
- ? Awaited<R>
799
+ /** Awaited return type of a service's helpers factory. Check key presence
800
+ * before removing optionality: annotations keep `helpers?`, while plain
801
+ * service literals with no factory must stay out of ctx.svc. */
802
+ type HelpersOf<D> = "helpers" extends keyof D
803
+ ? [NonNullable<D["helpers"]>] extends [never]
804
+ ? never
805
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
806
+ : NonNullable<D["helpers"]> extends (...args: any[]) => infer R
807
+ ? Awaited<R>
808
+ : never
806
809
  : never;
807
810
 
808
811
  /**