@specific.dev/spectest 0.31.0 → 0.32.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
@@ -2553,7 +2553,11 @@ async function bootstrapInner() {
2553
2553
  if (svc.setup) {
2554
2554
  progressService(svc.name, { status: "probing", detail: "running setup" });
2555
2555
  const helpers = await ensureHelpers(svc.name, svc);
2556
- await svc.setup({ name: svc.name, helpers, ...componentContext() });
2556
+ await svc.setup({
2557
+ name: svc.name,
2558
+ helpers,
2559
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
2560
+ });
2557
2561
  }
2558
2562
  progressService(svc.name, { status: "ready", detail: undefined });
2559
2563
  const ti = timings.get(svc.name);
@@ -2622,25 +2626,15 @@ async function runProjectSetupInner() {
2622
2626
  if (!proj.setup)
2623
2627
  return { ran: false, durationMs: 0 };
2624
2628
  const start = Date.now();
2625
- // Build the same `svc` handles tests see, so setup and tests share
2626
- // helper instances (e.g. a Bun.SQL pool created here is reused later).
2627
- const svc = (await buildServiceHandles(proj.environment));
2628
- const fakes = await buildFakeHandles();
2629
2629
  // Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
2630
2630
  // client routed through `globalThis.fetch`) returns a wrapped Response, same
2631
2631
  // as in a test. No recorder is active here, so it wraps without provenance —
2632
2632
  // but the wrapped type stays honest at runtime (`.unwrap()` works).
2633
2633
  const restoreFetch = installFetchWrapper();
2634
- const ctx = {
2635
- fetch: globalThis.fetch,
2636
- exec: execInServiceWrapped,
2637
- svc,
2638
- fakes,
2639
- dnsName: registerDnsName,
2640
- certificate: mintCertificate,
2641
- startService: startRuntimeService,
2642
- stopService: stopRuntimeService,
2643
- };
2634
+ // The full context, unscoped: every service is up by project-setup time,
2635
+ // so `ctx.svc` is the same map tests see (and shares helper instances with
2636
+ // them — a Bun.SQL pool created here is reused later).
2637
+ const ctx = await spectestContext();
2644
2638
  try {
2645
2639
  await proj.setup(ctx);
2646
2640
  }
@@ -3013,63 +3007,131 @@ const TEST_DATA = new Map();
3013
3007
  // so components don't hand-roll child_process docker execs or hard-code
3014
3008
  // control-plane paths like /workspace.
3015
3009
  // ────────────────────────────────────────────────────────────────────────
3010
+ /** Default `ctx.exec` timeout inside a service-level `setup`/`helpers`
3011
+ * hook. Those run during boot where nothing else bounds them, so a wedged
3012
+ * command would hang the whole env start. A test's exec inherits the
3013
+ * test timeout instead, and a project `setup` — which routinely waits on
3014
+ * rollouts — stays unbounded, so neither gets a default. */
3016
3015
  const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
3017
3016
  /** Raw `docker exec` with optional piped stdin. Array command = exact
3018
3017
  * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
3019
- * `exitCode`, never thrown. Unlike `execInService` this records nothing —
3020
- * setup/helpers-factory time has no test timeline. */
3021
- function componentExec(service, command, opts) {
3018
+ * `exitCode`, never thrown. Unlike the test-context `exec` this records
3019
+ * nothing — setup/helpers-factory time has no test timeline. */
3020
+ function componentExec(service, command, opts, defaultTimeoutMs) {
3022
3021
  assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3023
- const argv = ["exec", "-i"];
3024
- if (opts?.cwd)
3025
- argv.push("-w", opts.cwd);
3026
- argv.push(service);
3027
- if (typeof command === "string")
3028
- argv.push("sh", "-lc", command);
3029
- else
3030
- argv.push(...command);
3031
- const timeoutMs = opts?.timeoutMs ?? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS;
3032
- return new Promise((resolve, reject) => {
3033
- const child = spawn("docker", argv, {
3034
- stdio: [opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
3035
- });
3036
- const out = [];
3037
- const err = [];
3038
- child.stdout.on("data", (c) => out.push(c));
3039
- child.stderr.on("data", (c) => err.push(c));
3040
- let timedOut = false;
3041
- const timer = setTimeout(() => {
3042
- timedOut = true;
3043
- child.kill("SIGKILL");
3044
- }, timeoutMs);
3045
- child.on("error", (e) => {
3046
- clearTimeout(timer);
3047
- reject(e);
3048
- });
3049
- child.on("close", (code) => {
3050
- clearTimeout(timer);
3051
- // A SIGKILL'd child reports 137, which reads like an OOM kill.
3052
- // Report the `timeout(1)` convention plus a note instead, so a
3053
- // wedged command is diagnosable from the result alone.
3054
- const stderrText = Buffer.concat(err).toString("utf8");
3055
- resolve({
3056
- stdout: Buffer.concat(out).toString("utf8"),
3057
- stderr: timedOut
3058
- ? `${stderrText}\ntimeout after ${timeoutMs}ms`
3059
- : stderrText,
3060
- exitCode: timedOut ? EXEC_TIMEOUT_EXIT_CODE : (code ?? -1),
3061
- });
3062
- });
3063
- feedStdin(child, opts?.stdin);
3022
+ const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs;
3023
+ return execInService(service, command, {
3024
+ ...opts,
3025
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3026
+ });
3027
+ }
3028
+ /** Transitive `dependsOn` closure of a service, by name. */
3029
+ function transitiveDeps(service) {
3030
+ const services = loaded?.project.environment.services ?? {};
3031
+ const seen = new Set();
3032
+ const walk = (name) => {
3033
+ for (const dep of services[name]?.dependsOn ?? []) {
3034
+ if (seen.has(dep) || !services[dep])
3035
+ continue;
3036
+ seen.add(dep);
3037
+ walk(dep);
3038
+ }
3039
+ };
3040
+ walk(service);
3041
+ return seen;
3042
+ }
3043
+ /** Property reads that are language plumbing, not a service lookup —
3044
+ * `then` above all: a context object holding this map gets awaited, and a
3045
+ * throwing `then` read would turn `await ctx` into a hard failure. */
3046
+ const SVC_PLUMBING_PROPS = ["then", "toJSON", "constructor"];
3047
+ /**
3048
+ * `svc` for a service-level hook: the handles of everything the DAG says
3049
+ * is up, behind a Proxy that turns a read of anything else into an error
3050
+ * naming the fix rather than a `undefined is not an object` further down.
3051
+ */
3052
+ function scopedServiceHandles(built, scope) {
3053
+ const allowed = transitiveDeps(scope.service);
3054
+ if (scope.includeSelf)
3055
+ allowed.add(scope.service);
3056
+ return new Proxy(built.handles, {
3057
+ get(target, prop, receiver) {
3058
+ if (typeof prop === "string" &&
3059
+ !SVC_PLUMBING_PROPS.includes(prop) &&
3060
+ !(prop in target)) {
3061
+ // A dependency whose own `helpers` factory blew up: surface THAT
3062
+ // error, and only if someone actually reads the handle — a hook
3063
+ // that never touches `ctx.svc` shouldn't fail over a sibling's
3064
+ // broken factory.
3065
+ const failure = built.failures.get(prop);
3066
+ if (failure)
3067
+ throw failure;
3068
+ if (!allowed.has(prop)) {
3069
+ const known = loaded?.project.environment.services ?? {};
3070
+ throw new Error(known[prop]
3071
+ ? `ctx.svc.${prop} is not available in ${scope.service}'s ${scope.includeSelf ? "setup" : "helpers"} hook: "${prop}" is not a dependency of "${scope.service}", so it may not be running yet. Add it to ${scope.service}'s \`dependsOn\`.`
3072
+ : `ctx.svc.${prop}: no service named "${prop}" in this environment.`);
3073
+ }
3074
+ }
3075
+ return Reflect.get(target, prop, receiver);
3076
+ },
3064
3077
  });
3065
3078
  }
3066
- function componentContext() {
3079
+ /**
3080
+ * Build the context every hook receives. `scope` decides which services'
3081
+ * handles are reachable and whether `exec` gets the boot-time default
3082
+ * timeout; everything else is identical in every context.
3083
+ */
3084
+ async function spectestContext(scope = {}) {
3085
+ const handles = scope.service
3086
+ ? scopedServiceHandles(await buildScopedHandles(scope.service, scope.includeSelf === true), { ...scope, service: scope.service })
3087
+ : loaded
3088
+ ? await buildServiceHandles(loaded.project.environment)
3089
+ : {};
3090
+ const fakes = loaded ? await buildFakeHandles() : {};
3091
+ const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
3067
3092
  return {
3068
3093
  projectRoot: WORKSPACE,
3069
3094
  readProjectFile: (p) => fs.readFile(path.isAbsolute(p) ? p : path.join(WORKSPACE, p), "utf8"),
3070
- exec: componentExec,
3095
+ exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
3096
+ // Read `globalThis.fetch` at call time: the wrapper is installed for
3097
+ // the duration of a test / eval / project setup, so a context built
3098
+ // before it lands still gets the wrapped one.
3099
+ fetch: ((input, init) => globalThis.fetch(input, init)),
3100
+ svc: handles,
3101
+ fakes: fakes,
3102
+ poll: pollCall,
3103
+ dnsName: registerDnsName,
3104
+ certificate: mintCertificate,
3105
+ startService: startRuntimeService,
3106
+ stopService: stopRuntimeService,
3071
3107
  };
3072
3108
  }
3109
+ /** Handles for a service hook: its dependencies (always up by the time the
3110
+ * hook runs) plus, for a `setup` hook, the service itself. Built eagerly —
3111
+ * the factories are cached, so a later test reuses these instances, and
3112
+ * building them here puts them in the warm-template snapshot. A factory
3113
+ * that throws is recorded rather than propagated: only a hook that
3114
+ * actually reads that handle should fail (see `scopedServiceHandles`). */
3115
+ async function buildScopedHandles(service, includeSelf) {
3116
+ const services = loaded?.project.environment.services ?? {};
3117
+ const names = transitiveDeps(service);
3118
+ if (includeSelf)
3119
+ names.add(service);
3120
+ const handles = {};
3121
+ const failures = new Map();
3122
+ for (const name of names) {
3123
+ const def = services[name];
3124
+ if (!def?.helpers)
3125
+ continue;
3126
+ try {
3127
+ handles[name] = await ensureHelpers(name, def);
3128
+ }
3129
+ catch (err) {
3130
+ failures.set(name, err);
3131
+ }
3132
+ }
3133
+ return { handles, failures };
3134
+ }
3073
3135
  // Cached helper namespaces produced by `ServiceDefinition.helpers`
3074
3136
  // factories. Built lazily on first access and reused for the daemon's
3075
3137
  // lifetime — Bun.SQL pools and similar resources are happy to live a
@@ -3086,10 +3148,30 @@ async function ensureHelpers(name, def) {
3086
3148
  if (!def.helpers)
3087
3149
  return {};
3088
3150
  if (!HELPERS_CACHE.has(name)) {
3089
- HELPERS_CACHE.set(name, await def.helpers({ name, ...componentContext() }));
3151
+ // A factory can now read `ctx.svc` (its dependencies'), so it can
3152
+ // re-enter this function. Deps are a DAG, but a factory reaching a
3153
+ // NON-dependency it happens to be reachable from would spin forever —
3154
+ // report the cycle instead of hanging the boot.
3155
+ if (HELPERS_BUILDING.has(name)) {
3156
+ throw new Error(`helpers factory cycle: building ctx.svc.${name} re-entered itself ` +
3157
+ `(via ${[...HELPERS_BUILDING].join(" → ")}). A helpers factory can read its ` +
3158
+ `dependencies' handles, but they must not read back.`);
3159
+ }
3160
+ HELPERS_BUILDING.add(name);
3161
+ try {
3162
+ HELPERS_CACHE.set(name, await def.helpers({
3163
+ name,
3164
+ ...(await spectestContext({ service: name, includeSelf: false })),
3165
+ }));
3166
+ }
3167
+ finally {
3168
+ HELPERS_BUILDING.delete(name);
3169
+ }
3090
3170
  }
3091
3171
  return HELPERS_CACHE.get(name);
3092
3172
  }
3173
+ /** Names whose `helpers` factory is mid-flight — see the cycle check above. */
3174
+ const HELPERS_BUILDING = new Set();
3093
3175
  /**
3094
3176
  * Build the `svc` map for one test/eval. The value at `svc[name]` is
3095
3177
  * exactly the record the service's `helpers` factory returned (e.g.
@@ -3209,21 +3291,32 @@ function installFetchWrapper() {
3209
3291
  globalThis.fetch = original;
3210
3292
  };
3211
3293
  }
3212
- /** Build the `docker exec` argv for a service command. An optional `cwd`
3213
- * becomes `-w <cwd>` (the working-directory option of `ctx.exec`), so the
3214
- * command runs from that directory without it being baked into the command
3215
- * string. `-i` is passed whenever stdin will be piped — without it docker
3216
- * attaches no stdin and the payload is silently discarded. Used by both the
3217
- * buffered and streaming variants so they stay in lockstep. */
3294
+ /** Build the `docker exec` argv for a service command. A string command runs
3295
+ * through `sh -lc`; an array is exact argv with no shell (the
3296
+ * `["psql", "-f", "-"]` shape). An optional `cwd` becomes `-w <cwd>` (the
3297
+ * working-directory option of `ctx.exec`), so the command runs from that
3298
+ * directory without it being baked into the command string. `-i` is passed
3299
+ * whenever stdin will be piped — without it docker attaches no stdin and the
3300
+ * payload is silently discarded. Used by both the buffered and streaming
3301
+ * variants so they stay in lockstep. */
3218
3302
  function dockerExecArgs(service, command, opts) {
3219
3303
  const args = ["exec"];
3220
3304
  if (opts?.stdin !== undefined)
3221
3305
  args.push("-i");
3222
3306
  if (opts?.cwd)
3223
3307
  args.push("-w", opts.cwd);
3224
- args.push(service, "sh", "-lc", command);
3308
+ args.push(service);
3309
+ if (typeof command === "string")
3310
+ args.push("sh", "-lc", command);
3311
+ else
3312
+ args.push(...command);
3225
3313
  return args;
3226
3314
  }
3315
+ /** How an argv-array command is shown in the timeline / asciicast prompt.
3316
+ * Display only — the real invocation never goes through a shell. */
3317
+ function displayCommand(command) {
3318
+ return typeof command === "string" ? command : command.join(" ");
3319
+ }
3227
3320
  /** Exit code reported when an exec is killed by its own `timeoutMs`
3228
3321
  * (the `timeout(1)` convention, matching `shx`). */
3229
3322
  const EXEC_TIMEOUT_EXIT_CODE = 124;
@@ -3515,14 +3608,17 @@ async function runOne(testCase) {
3515
3608
  const t = Date.now();
3516
3609
  const cwd = opts?.cwd;
3517
3610
  const resv = reserveEvent();
3518
- const session = newTerminalSession(start, service, command, EXEC_CAST_COLS, EXEC_CAST_ROWS, testCase.id);
3611
+ // An argv array has no shell form to show; render it space-joined for
3612
+ // the prompt line and the timeline row (display only).
3613
+ const shown = displayCommand(command);
3614
+ const session = newTerminalSession(start, service, shown, EXEC_CAST_COLS, EXEC_CAST_ROWS, testCase.id);
3519
3615
  terminalSessions.push(session.record);
3520
3616
  // Synthetic prompt frame so the replay is self-describing — the
3521
3617
  // program's own output starts on the next line, like a real shell. A
3522
3618
  // working directory rides in the prompt sigil (`svc:/dir $`) the way a
3523
3619
  // real shell prompt shows it, so the recording stays self-describing.
3524
3620
  const sigil = cwd ? `${service}:${cwd}` : service;
3525
- session.pushFrame(0, `\x1b[32m${sigil} $\x1b[0m \x1b[1m${command}\x1b[0m\r\n`);
3621
+ session.pushFrame(0, `\x1b[32m${sigil} $\x1b[0m \x1b[1m${shown}\x1b[0m\r\n`);
3526
3622
  let frameBytes = 0;
3527
3623
  let frameCapped = false;
3528
3624
  const res = await execInServiceStreaming(service, command, (_stream, data) => {
@@ -3551,7 +3647,7 @@ async function runOne(testCase) {
3551
3647
  const stdin = opts?.stdin !== undefined ? truncateUtf8(opts.stdin) : undefined;
3552
3648
  const seq = recordExec({
3553
3649
  service,
3554
- command,
3650
+ command: shown,
3555
3651
  cwd,
3556
3652
  ...(stdin
3557
3653
  ? { stdin: stdin.value, stdinTruncated: stdin.truncated }
@@ -3713,20 +3809,16 @@ async function runOne(testCase) {
3713
3809
  sharedMobiles.set(app.url, mobile);
3714
3810
  return mobile;
3715
3811
  };
3716
- // Build convenience handles (e.g. ctx.svc.db.client) from the loaded
3717
- // project. Done before installing the timeout so a slow client factory
3718
- // surfaces as a real error rather than getting attributed to the test.
3719
- const svc = await buildServiceHandles(requireLoaded().project.environment);
3720
- const fakes = await buildFakeHandles();
3812
+ // The shared context (svc/fakes handles, poll, project files, dnsName,
3813
+ // certificate, runtime services) — identical to what a `setup` hook gets.
3814
+ // Built before installing the timeout so a slow client factory surfaces as
3815
+ // a real error rather than getting attributed to the test.
3816
+ const base = await spectestContext();
3721
3817
  const ctx = {
3722
- // installFetchWrapper just swapped globalThis.fetch for the wrapped
3723
- // version, so capturing it here gets us instrumentation on ctx.fetch
3724
- // for free. The recorder is active for the test, so responses come
3725
- // back wrapped — hence the SpectestFetch type.
3726
- fetch: globalThis.fetch,
3818
+ ...base,
3727
3819
  // exec/terminal/poll wrap their results at runtime (the recorder is
3728
- // active), so the ctx interface types them wrapped — same bridge as
3729
- // `fetch` above. The impls' own return types stay raw.
3820
+ // active), so the ctx interface types them wrapped. The impls' own
3821
+ // return types stay raw — hence the bridging casts.
3730
3822
  exec: recordedExec,
3731
3823
  terminal: recordedTerminal,
3732
3824
  openTerminal: recordedOpenTerminal,
@@ -3734,13 +3826,6 @@ async function runOne(testCase) {
3734
3826
  mobile: trackedOpenMobile,
3735
3827
  testName: testCase.name,
3736
3828
  parent,
3737
- svc,
3738
- fakes,
3739
- poll: pollCall,
3740
- dnsName: registerDnsName,
3741
- certificate: mintCertificate,
3742
- startService: startRuntimeService,
3743
- stopService: stopRuntimeService,
3744
3829
  };
3745
3830
  const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
3746
3831
  let timer;
@@ -4072,19 +4157,14 @@ async function evalCode(code, secrets) {
4072
4157
  const evalOpenTerminal = async (service, opts) => {
4073
4158
  return openInstrumentedTerminal(service, opts, start, terminalSessions, false, "eval");
4074
4159
  };
4075
- // Convenience handles are best-effort for eval — if the project isn't
4076
- // loaded yet, fall back to an empty map so quick `await fetch(...)`
4160
+ // The shared context. Handles are best-effort for eval — with no project
4161
+ // loaded `spectestContext()` yields empty maps, so quick `await fetch(...)`
4077
4162
  // snippets don't require a /load round-trip first.
4078
- const svc = loaded
4079
- ? await buildServiceHandles(loaded.project.environment)
4080
- : {};
4081
- const fakes = loaded ? await buildFakeHandles() : {};
4163
+ const base = await spectestContext();
4082
4164
  const ctx = {
4083
- // installFetchWrapper swapped globalThis.fetch above, so this captures the
4084
- // wrapped version — eval results are wrapped just like in a test.
4085
- fetch: globalThis.fetch,
4086
- // Wraps its result the same way (no recorder under eval, so no provenance —
4087
- // but the wrapped type is honest at runtime, so `.unwrap()` works).
4165
+ ...base,
4166
+ // Wraps its result (no recorder under eval, so no provenance — but the
4167
+ // wrapped type is honest at runtime, so `.unwrap()` works).
4088
4168
  exec: execInServiceWrapped,
4089
4169
  terminal: evalTerminal,
4090
4170
  openTerminal: evalOpenTerminal,
@@ -4092,13 +4172,6 @@ async function evalCode(code, secrets) {
4092
4172
  mobile: trackedOpenMobile,
4093
4173
  testName: "eval",
4094
4174
  parent: undefined,
4095
- svc,
4096
- fakes,
4097
- poll: pollCall,
4098
- dnsName: registerDnsName,
4099
- certificate: mintCertificate,
4100
- startService: startRuntimeService,
4101
- stopService: stopRuntimeService,
4102
4175
  };
4103
4176
  // Expose the test context, matchers, and persistent state as globals
4104
4177
  // so the snippet can use them without an explicit import. The user code