@specific.dev/spectest 0.108.0 → 0.109.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
@@ -2925,31 +2925,34 @@ async function bootstrapInner(prepared, identity) {
2925
2925
  logBootstrapTimings(result);
2926
2926
  return result;
2927
2927
  }
2928
+ // Captured as false in the shared template, true in a seeded interactive
2929
+ // snapshot. Repeated requests must not apply a successful seed twice.
2930
+ let interactiveSetupCompleted = false;
2928
2931
  /**
2929
- * Run the loaded project's `setup` hook, if any. Called by the control
2930
- * plane once between /bootstrap and the warm-template snapshot, so the
2931
- * effects (seeded DB rows, initial pods, fixture files) are captured
2932
- * exactly once and inherited by every later snapshot/fork.
2932
+ * Run a project setup hook. Normal `setup` runs before the shared warm
2933
+ * snapshot; `setupInteractive` runs only after it, in a fresh user start.
2934
+ * Both use the same context and boot-log capture.
2933
2935
  *
2934
2936
  * Unlike test runs, this is NOT instrumented — no recorder, no event
2935
2937
  * timeline, no timeout from the test runner. Setup failures abort the
2936
2938
  * env bring-up; the control plane surfaces them as a start failure.
2937
2939
  */
2938
- async function runProjectSetup() {
2940
+ async function runProjectSetup(hook = "setup") {
2939
2941
  // Same tee as bootstrap: a project `setup` hook's console output is
2940
2942
  // part of explaining the boot, not something to lose on success.
2941
2943
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2942
2944
  try {
2943
- return await runProjectSetupInner();
2945
+ return await runProjectSetupInner(hook);
2944
2946
  }
2945
2947
  finally {
2946
2948
  restoreBootLog();
2947
2949
  }
2948
2950
  }
2949
- async function runProjectSetupInner() {
2950
- const proj = requireLoaded().project;
2951
- if (!proj.setup)
2951
+ async function runProjectSetupInner(hook) {
2952
+ const setup = requireLoaded().project[hook];
2953
+ if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
2952
2954
  return { ran: false, durationMs: 0 };
2955
+ }
2953
2956
  const start = Date.now();
2954
2957
  // Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
2955
2958
  // client routed through `globalThis.fetch`) returns a wrapped Response, same
@@ -2960,9 +2963,11 @@ async function runProjectSetupInner() {
2960
2963
  // The full context, unscoped: every service is up by project-setup time,
2961
2964
  // so `ctx.svc` is the same map tests see (and shares helper instances with
2962
2965
  // them — a Bun.SQL pool created here is reused later).
2963
- const ctx = await spectestContext();
2964
2966
  try {
2965
- await runInstrumented(fetchScope, () => proj.setup(ctx));
2967
+ const ctx = await spectestContext();
2968
+ await runInstrumented(fetchScope, () => setup(ctx));
2969
+ if (hook === "setupInteractive")
2970
+ interactiveSetupCompleted = true;
2966
2971
  }
2967
2972
  finally {
2968
2973
  fetchScope.active = false;
@@ -5131,7 +5136,13 @@ export function harnessMethods(state) {
5131
5136
  setRecordSecrets(secrets);
5132
5137
  };
5133
5138
  return {
5134
- health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
5139
+ health: async () => ({
5140
+ ok: true,
5141
+ capabilities: ["prepared-images-v1"],
5142
+ // Optional discovery: old SDKs omit this field and are never sent the
5143
+ // new method. Check the loaded project, including after warm restore.
5144
+ setupInteractive: typeof loaded?.project.setupInteractive === "function",
5145
+ }),
5135
5146
  // Live bootstrap progress, polled by the control plane during
5136
5147
  // `bootstrap` and streamed into the test-run row. `{}` before the
5137
5148
  // first bootstrap of this harness process.
@@ -5248,6 +5259,7 @@ export function harnessMethods(state) {
5248
5259
  return { ok: true, timings: await bootstrap(prepared) };
5249
5260
  }),
5250
5261
  projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
5262
+ interactiveSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup("setupInteractive")),
5251
5263
  eval: async (params) => {
5252
5264
  const code = requireString(params, "code");
5253
5265
  return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
@@ -23,7 +23,7 @@
23
23
  /** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
24
24
  export declare const PROTOCOL_VERSION = 1;
25
25
  /** Methods either side can send. */
26
- export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
26
+ export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "interactiveSetup" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
27
27
  /** Methods this build knows how to dispatch. A method outside this set still
28
28
  * *parses* — it is answered with an error, never dropped. */
29
29
  export declare const KNOWN_METHODS: ReadonlySet<string>;
@@ -30,6 +30,7 @@ export const KNOWN_METHODS = new Set([
30
30
  "loadTests",
31
31
  "fingerprint",
32
32
  "bootstrap",
33
+ "interactiveSetup",
33
34
  "run",
34
35
  "eval",
35
36
  "setRecordSecrets",
@@ -83,9 +83,11 @@ function invokeHelper(kind, name, member, fn, receiver, callArgs) {
83
83
  };
84
84
  let result;
85
85
  try {
86
+ // Reading a callable proxy's `.apply` can bind directly to its raw client
87
+ // and bypass instrumentation (SQL does this). Invoke the proxy itself.
86
88
  result = recording.run(() => kind === "fake"
87
- ? runUninstrumented(() => fn.apply(receiver, args))
88
- : fn.apply(receiver, args));
89
+ ? runUninstrumented(() => Reflect.apply(fn, receiver, args))
90
+ : Reflect.apply(fn, receiver, args));
89
91
  }
90
92
  catch (error) {
91
93
  return fail(error);
package/dist/index.d.ts CHANGED
@@ -1624,17 +1624,25 @@ export interface Project<S extends ServicesMap = ServicesMap, F extends FakesMap
1624
1624
  * appear in the timeline.
1625
1625
  */
1626
1626
  setup?: ProjectSetupFn<S, F>;
1627
+ /**
1628
+ * Prepare a new interactive environment, after normal setup and the shared
1629
+ * warm-template snapshot. Runs on both cold starts and warm restores from
1630
+ * the dashboard or `spectest env start`, before the environment is ready.
1631
+ * Never runs for tests, snapshot forks, or resuming a stopped environment.
1632
+ * Uses the same context as `setup`; throw to fail the start.
1633
+ */
1634
+ setupInteractive?: ProjectSetupFn<S, F>;
1627
1635
  tests?: TestSuite<S, F>;
1628
1636
  }
1629
1637
  /**
1630
- * Context handed to the project-level `setup` hook: the full
1638
+ * Context handed to the project-level `setup` and `setupInteractive` hooks: the full
1631
1639
  * {@link SpectestContext}, minus only what a timeline gives a test
1632
1640
  * (testName, parent, browser/mobile/terminal). Everything a test can do
1633
1641
  * to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
1634
1642
  * `dnsName`, `certificate`, `startService`, and project-file access via
1635
- * `projectRoot`/`readProjectFile` — is available here too, and whatever it
1636
- * produces is captured into the warm-template snapshot, so every test
1637
- * inherits it without re-running.
1643
+ * `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
1644
+ * state is captured into the shared warm template; `setupInteractive`
1645
+ * state belongs only to the new interactive environment.
1638
1646
  */
1639
1647
  export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> extends SpectestContext<S, F> {
1640
1648
  }
@@ -1673,6 +1681,8 @@ export type ProjectSetupFn<S extends ServicesMap = ServicesMap, F extends FakesM
1673
1681
  export interface ProjectOpts<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
1674
1682
  /** Optional project-level setup. See `Project.setup`. */
1675
1683
  setup?: ProjectSetupFn<S, F>;
1684
+ /** Optional interactive-only setup. See `Project.setupInteractive`. */
1685
+ setupInteractive?: ProjectSetupFn<S, F>;
1676
1686
  /** Test cases for this project's suite. */
1677
1687
  tests?: TestCase<unknown, S, F>[];
1678
1688
  }
@@ -1726,7 +1736,7 @@ export interface DefinedEnvironment<S extends ServicesMap, F extends FakesMap =
1726
1736
  group<P = undefined>(name: string, opts?: GroupOpts<P, S, F>): TestGroup<P, S, F>;
1727
1737
  /** Bundle this environment with a test suite into the project default
1728
1738
  * export. Pass tests as a plain array for the common case, or an
1729
- * options bag to attach project-level `setup`. (Fakes are declared on
1739
+ * options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
1730
1740
  * `defineEnvironment`, not here.) */
1731
1741
  project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
1732
1742
  project(opts: ProjectOpts<S, F>): Project<S, F>;
package/dist/index.js CHANGED
@@ -484,6 +484,8 @@ export function defineEnvironment(input) {
484
484
  const proj = { environment: config };
485
485
  if (opts.setup)
486
486
  proj.setup = opts.setup;
487
+ if (opts.setupInteractive)
488
+ proj.setupInteractive = opts.setupInteractive;
487
489
  if (fakes)
488
490
  proj.fakes = fakes;
489
491
  if (opts.tests && opts.tests.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.108.0",
3
+ "version": "0.109.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
@@ -3473,30 +3473,35 @@ interface ProjectSetupResult {
3473
3473
  durationMs: number;
3474
3474
  }
3475
3475
 
3476
+ // Captured as false in the shared template, true in a seeded interactive
3477
+ // snapshot. Repeated requests must not apply a successful seed twice.
3478
+ let interactiveSetupCompleted = false;
3479
+
3476
3480
  /**
3477
- * Run the loaded project's `setup` hook, if any. Called by the control
3478
- * plane once between /bootstrap and the warm-template snapshot, so the
3479
- * effects (seeded DB rows, initial pods, fixture files) are captured
3480
- * exactly once and inherited by every later snapshot/fork.
3481
+ * Run a project setup hook. Normal `setup` runs before the shared warm
3482
+ * snapshot; `setupInteractive` runs only after it, in a fresh user start.
3483
+ * Both use the same context and boot-log capture.
3481
3484
  *
3482
3485
  * Unlike test runs, this is NOT instrumented — no recorder, no event
3483
3486
  * timeline, no timeout from the test runner. Setup failures abort the
3484
3487
  * env bring-up; the control plane surfaces them as a start failure.
3485
3488
  */
3486
- async function runProjectSetup(): Promise<ProjectSetupResult> {
3489
+ async function runProjectSetup(hook: "setup" | "setupInteractive" = "setup"): Promise<ProjectSetupResult> {
3487
3490
  // Same tee as bootstrap: a project `setup` hook's console output is
3488
3491
  // part of explaining the boot, not something to lose on success.
3489
3492
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3490
3493
  try {
3491
- return await runProjectSetupInner();
3494
+ return await runProjectSetupInner(hook);
3492
3495
  } finally {
3493
3496
  restoreBootLog();
3494
3497
  }
3495
3498
  }
3496
3499
 
3497
- async function runProjectSetupInner(): Promise<ProjectSetupResult> {
3498
- const proj = requireLoaded().project;
3499
- if (!proj.setup) return { ran: false, durationMs: 0 };
3500
+ async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise<ProjectSetupResult> {
3501
+ const setup = requireLoaded().project[hook];
3502
+ if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
3503
+ return { ran: false, durationMs: 0 };
3504
+ }
3500
3505
  const start = Date.now();
3501
3506
  // Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
3502
3507
  // client routed through `globalThis.fetch`) returns a wrapped Response, same
@@ -3507,9 +3512,10 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
3507
3512
  // The full context, unscoped: every service is up by project-setup time,
3508
3513
  // so `ctx.svc` is the same map tests see (and shares helper instances with
3509
3514
  // them — a Bun.SQL pool created here is reused later).
3510
- const ctx: ProjectSetupContext = await spectestContext();
3511
3515
  try {
3512
- await runInstrumented(fetchScope, () => proj.setup!(ctx));
3516
+ const ctx: ProjectSetupContext = await spectestContext();
3517
+ await runInstrumented(fetchScope, () => setup(ctx));
3518
+ if (hook === "setupInteractive") interactiveSetupCompleted = true;
3513
3519
  } finally {
3514
3520
  fetchScope.active = false;
3515
3521
  restoreFetch();
@@ -6148,7 +6154,13 @@ export function harnessMethods(state: RouteState): MethodTable {
6148
6154
  setRecordSecrets(secrets as Record<string, string>);
6149
6155
  };
6150
6156
  return {
6151
- health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
6157
+ health: async () => ({
6158
+ ok: true,
6159
+ capabilities: ["prepared-images-v1"],
6160
+ // Optional discovery: old SDKs omit this field and are never sent the
6161
+ // new method. Check the loaded project, including after warm restore.
6162
+ setupInteractive: typeof loaded?.project.setupInteractive === "function",
6163
+ }),
6152
6164
 
6153
6165
  // Live bootstrap progress, polled by the control plane during
6154
6166
  // `bootstrap` and streamed into the test-run row. `{}` before the
@@ -6285,6 +6297,14 @@ export function harnessMethods(state: RouteState): MethodTable {
6285
6297
  () => runProjectSetup(),
6286
6298
  ),
6287
6299
 
6300
+ interactiveSetup: async () =>
6301
+ exclusive(
6302
+ state,
6303
+ "inFlightProjectSetup",
6304
+ "project-setup already in progress",
6305
+ () => runProjectSetup("setupInteractive"),
6306
+ ),
6307
+
6288
6308
  eval: async (params) => {
6289
6309
  const code = requireString(params, "code");
6290
6310
  return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
@@ -32,6 +32,7 @@ export type Method =
32
32
  | "loadTests"
33
33
  | "fingerprint"
34
34
  | "bootstrap"
35
+ | "interactiveSetup"
35
36
  | "run"
36
37
  | "eval"
37
38
  | "setRecordSecrets"
@@ -51,6 +52,7 @@ export const KNOWN_METHODS: ReadonlySet<string> = new Set<Method>([
51
52
  "loadTests",
52
53
  "fingerprint",
53
54
  "bootstrap",
55
+ "interactiveSetup",
54
56
  "run",
55
57
  "eval",
56
58
  "setRecordSecrets",
@@ -3,6 +3,7 @@ import { annotate } from "./annotate.js";
3
3
  import { expect as assertValue, type ServiceHandlesFor, type Wrapped } from "./index.js";
4
4
  import { readRaw, readTag, wrap } from "./inspect.js";
5
5
  import { trackHelpers } from "./helper-tracking.js";
6
+ import { SQL } from "./sql.js";
6
7
  import {
7
8
  recordHttp, recordWait, recorderEventCount, recorderMarkChildren, recorderTruncate,
8
9
  startRecording, stopRecording,
@@ -175,6 +176,41 @@ test("live objects, callable clients, function properties and receivers survive"
175
176
  expect<unknown>(readRaw(helper.latest.call({...raw, subject: "Other"}))).toEqual({subject: "Other"});
176
177
  });
177
178
 
179
+ test("a tracked SQL client keeps tagged queries and attached methods instrumented", async () => {
180
+ const client = new SQL("sqlite://:memory:", { label: "db" });
181
+ const helper = trackHelpers("service", "db", { client });
182
+ startRecording();
183
+ try {
184
+ const rows = await helper.client<{ id: number }>`SELECT ${7} AS id`;
185
+ assertValue(rows.transform("ids", (rows) => Array.from(rows, (row) => row.id))).toEqual([7]);
186
+ const other = await helper.client.unsafe<{ id: number }>("SELECT ? AS id", [8]);
187
+ assertValue(other[0]!.id).toBe(8);
188
+ const events = stopRecording();
189
+ expect(events.map((event) => event.kind)).toEqual(["db", "assertion", "db", "assertion"]);
190
+ expect(events[0]).toMatchObject({ service: "db", query: "SELECT $1 AS id", params: [7], rows: [{ id: 7 }] });
191
+ expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["<ids>"] });
192
+ expect(events[2]).toMatchObject({ service: "db", query: "SELECT ? AS id", params: [8], rows: [{ id: 8 }] });
193
+ expect(events[3]).toMatchObject({ sourceSeq: events[2]!.seq, path: ["0", "id"] });
194
+ } finally {
195
+ await helper.client.close!();
196
+ }
197
+ });
198
+
199
+ test("a tracked SQL client preserves query failure diagnostics", async () => {
200
+ const client = new SQL("sqlite://:memory:", { label: "db" });
201
+ const helper = trackHelpers("service", "db", { client });
202
+ startRecording();
203
+ try {
204
+ await expect(helper.client`SELECT * FROM missing_table`).rejects.toThrow("no such table");
205
+ const events = stopRecording();
206
+ expect(events).toHaveLength(1);
207
+ expect(events[0]).toMatchObject({ kind: "db", service: "db", query: "SELECT * FROM missing_table",
208
+ error: expect.stringContaining("no such table") });
209
+ } finally {
210
+ await helper.client.close!();
211
+ }
212
+ });
213
+
178
214
  test("setup/eval unwrap annotated returns without recording", async () => {
179
215
  const scope = createInstrumentationScope(false);
180
216
  const helper = trackHelpers("service", "mail", { latest: () => annotate({subject: "Invoice"}, "email") });
@@ -93,9 +93,11 @@ function invokeHelper(
93
93
  };
94
94
  let result: unknown;
95
95
  try {
96
+ // Reading a callable proxy's `.apply` can bind directly to its raw client
97
+ // and bypass instrumentation (SQL does this). Invoke the proxy itself.
96
98
  result = recording.run(() => kind === "fake"
97
- ? runUninstrumented(() => fn.apply(receiver, args))
98
- : fn.apply(receiver, args));
99
+ ? runUninstrumented(() => Reflect.apply(fn, receiver, args))
100
+ : Reflect.apply(fn, receiver, args));
99
101
  } catch (error) {
100
102
  return fail(error);
101
103
  }
package/src/index.ts CHANGED
@@ -2377,18 +2377,26 @@ export interface Project<
2377
2377
  * appear in the timeline.
2378
2378
  */
2379
2379
  setup?: ProjectSetupFn<S, F>;
2380
+ /**
2381
+ * Prepare a new interactive environment, after normal setup and the shared
2382
+ * warm-template snapshot. Runs on both cold starts and warm restores from
2383
+ * the dashboard or `spectest env start`, before the environment is ready.
2384
+ * Never runs for tests, snapshot forks, or resuming a stopped environment.
2385
+ * Uses the same context as `setup`; throw to fail the start.
2386
+ */
2387
+ setupInteractive?: ProjectSetupFn<S, F>;
2380
2388
  tests?: TestSuite<S, F>;
2381
2389
  }
2382
2390
 
2383
2391
  /**
2384
- * Context handed to the project-level `setup` hook: the full
2392
+ * Context handed to the project-level `setup` and `setupInteractive` hooks: the full
2385
2393
  * {@link SpectestContext}, minus only what a timeline gives a test
2386
2394
  * (testName, parent, browser/mobile/terminal). Everything a test can do
2387
2395
  * to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
2388
2396
  * `dnsName`, `certificate`, `startService`, and project-file access via
2389
- * `projectRoot`/`readProjectFile` — is available here too, and whatever it
2390
- * produces is captured into the warm-template snapshot, so every test
2391
- * inherits it without re-running.
2397
+ * `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
2398
+ * state is captured into the shared warm template; `setupInteractive`
2399
+ * state belongs only to the new interactive environment.
2392
2400
  */
2393
2401
  export interface ProjectSetupContext<
2394
2402
  S extends ServicesMap = ServicesMap,
@@ -2437,6 +2445,8 @@ export interface ProjectOpts<
2437
2445
  > {
2438
2446
  /** Optional project-level setup. See `Project.setup`. */
2439
2447
  setup?: ProjectSetupFn<S, F>;
2448
+ /** Optional interactive-only setup. See `Project.setupInteractive`. */
2449
+ setupInteractive?: ProjectSetupFn<S, F>;
2440
2450
  /** Test cases for this project's suite. */
2441
2451
  tests?: TestCase<unknown, S, F>[];
2442
2452
  }
@@ -2501,7 +2511,7 @@ export interface DefinedEnvironment<
2501
2511
  ): TestGroup<P, S, F>;
2502
2512
  /** Bundle this environment with a test suite into the project default
2503
2513
  * export. Pass tests as a plain array for the common case, or an
2504
- * options bag to attach project-level `setup`. (Fakes are declared on
2514
+ * options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
2505
2515
  * `defineEnvironment`, not here.) */
2506
2516
  project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
2507
2517
  project(opts: ProjectOpts<S, F>): Project<S, F>;
@@ -2653,6 +2663,7 @@ export function defineEnvironment<
2653
2663
  : (arg ?? {});
2654
2664
  const proj: Project<S, F> = { environment: config };
2655
2665
  if (opts.setup) proj.setup = opts.setup;
2666
+ if (opts.setupInteractive) proj.setupInteractive = opts.setupInteractive;
2656
2667
  if (fakes) proj.fakes = fakes;
2657
2668
  if (opts.tests && opts.tests.length > 0) {
2658
2669
  // Explicit suite (single-file / legacy layout): the tests are listed
@@ -0,0 +1,89 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // The daemon owns globals and snapshot-resident state. Use a fresh process,
7
+ // just as each freshly built environment has its own harness.
8
+ for (const withHook of [false, true]) {
9
+ test(`interactive setup: ${withHook ? "context, failure, exclusion and one-shot state" : "optional hook"}`, async () => {
10
+ const root = await mkdtemp(join(tmpdir(), "spectest-interactive-setup-"));
11
+ try {
12
+ await mkdir(join(root, "spectest"));
13
+ await mkdir(join(root, "bin"));
14
+ await writeFile(join(root, "bin/docker"), '#!/bin/sh\nprintf "%s\\n" "$*"\n', { mode: 0o755 });
15
+ const entry = join(root, "spectest/index.ts");
16
+ await writeFile(entry, `
17
+ import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
18
+ export const calls = [];
19
+ const env = defineEnvironment({ name: "seed", services: {
20
+ app: { image: { type: "registry", reference: "busybox" },
21
+ helpers: () => ({ label: "typed helper" }) },
22
+ }});
23
+ export default env.project({
24
+ setup: () => { calls.push("baseline"); },
25
+ ${withHook ? `setupInteractive: async (ctx) => {
26
+ calls.push("interactive");
27
+ console.log("seeding demo data");
28
+ await globalThis.seedGate;
29
+ if (globalThis.failSeed) throw new Error("seed rejected");
30
+ const response = await ctx.fetch("data:application/json,%7B%22ok%22%3Atrue%7D");
31
+ if (response.status.unwrap() !== 200) throw new Error("fetch not wrapped");
32
+ if (ctx.svc.app.label !== "typed helper") throw new Error("missing helpers");
33
+ if ("browser" in ctx || "testName" in ctx) throw new Error("test context leaked");
34
+ const result = await ctx.exec("app", ["seed", "demo"]);
35
+ if (typeof result.exitCode !== "number" || !result.stdout.includes("seed demo")) {
36
+ throw new Error("exec result not plain: " + JSON.stringify(result));
37
+ }
38
+ },` : ""}
39
+ });
40
+ `);
41
+ const script = `
42
+ import { strict as assert } from "node:assert";
43
+ import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
44
+ import { calls } from ${JSON.stringify(entry)};
45
+ const nativeFetch = globalThis.fetch;
46
+ await METHODS.load({});
47
+ assert.equal((await METHODS.health({})).setupInteractive, ${withHook});
48
+ await METHODS.projectSetup({});
49
+ assert.deepEqual(calls, ["baseline"]);
50
+ ${withHook ? `
51
+ let release;
52
+ globalThis.seedGate = new Promise(r => { release = r; });
53
+ globalThis.failSeed = true;
54
+ const failed = METHODS.interactiveSetup({});
55
+ await assert.rejects(METHODS.interactiveSetup({}), /already in progress/);
56
+ await assert.rejects(METHODS.projectSetup({}), /already in progress/);
57
+ release();
58
+ await assert.rejects(failed, /seed rejected/);
59
+ assert.equal(globalThis.fetch, nativeFetch);
60
+ globalThis.failSeed = false;
61
+ assert.equal((await METHODS.interactiveSetup({})).ran, true);
62
+ assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
63
+ // A repeated request (or a resumed seeded snapshot) cannot reseed.
64
+ assert.equal((await METHODS.interactiveSetup({})).ran, false);
65
+ assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
66
+ const logs = await METHODS.envLogs({});
67
+ assert.ok(logs.bootLog.includes("seeding demo data"));
68
+ ` : `
69
+ assert.equal((await METHODS.interactiveSetup({})).ran, false);
70
+ assert.deepEqual(calls, ["baseline"]);
71
+ `}
72
+ assert.equal(globalThis.fetch, nativeFetch);
73
+ // envLogs' subprocess timeout keeps a timer alive after it returns;
74
+ // this fixture tests hook behavior, not daemon process lifetime.
75
+ process.exit(0);
76
+ `;
77
+ const child = Bun.spawn([process.execPath, "--eval", script], {
78
+ env: { ...process.env, SPECTEST_APP_DIR: root, PATH: `${root}/bin:${process.env.PATH}` },
79
+ stdout: "pipe", stderr: "pipe",
80
+ });
81
+ const [code, stdout, stderr] = await Promise.all([
82
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
83
+ ]);
84
+ expect(code, stdout + stderr).toBe(0);
85
+ } finally {
86
+ await rm(root, { recursive: true, force: true });
87
+ }
88
+ }, 20_000);
89
+ }