@specific.dev/spectest 0.31.0 → 0.32.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/index.d.ts CHANGED
@@ -168,58 +168,240 @@ export interface ServiceConfig {
168
168
  cgroupns?: string;
169
169
  }
170
170
  /**
171
- * The slice of the in-VM world a component's `setup` / `helpers` hooks can
172
- * reach — beyond what the service definition itself declares. Handed to
173
- * both hooks (see {@link ServiceSetupContext} / {@link ServiceHelpersContext})
174
- * so components stop hand-rolling `child_process` docker execs and stop
175
- * hard-coding control-plane paths like `/workspace`.
171
+ * Everything test-side code can reach inside the VM, in every
172
+ * context it runs in: a test body, `spectest env eval`, a project-level
173
+ * `setup`, and a component's service-level `setup` / `helpers` hooks.
174
+ *
175
+ * This is deliberately ONE type. The setup surfaces used to be two
176
+ * disjoint sets — a component hook could read project files but not mint
177
+ * a certificate; a project `setup` could mint a certificate but not read
178
+ * a project file — so bring-up logic got split across files by which
179
+ * context happened to carry which method. A component is now written
180
+ * against exactly the primitives an end user has in a test.
181
+ *
182
+ * The one member that differs by context is {@link exec}: in a test it
183
+ * returns a {@link Wrapped} result (its output carries provenance into
184
+ * the timeline), everywhere else a plain one. See {@link TestContext}.
185
+ *
186
+ * `svc` is scoped in a service-level hook: a service's `setup`/`helpers`
187
+ * can reach its own handle and those of its (transitive) `dependsOn`
188
+ * services — the ones the DAG guarantees are already up — and reading
189
+ * any other key throws with the dependency it's missing. Everywhere else
190
+ * (tests, eval, project `setup`) the whole map is live.
176
191
  */
177
- export interface ComponentContext {
192
+ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
178
193
  /**
179
194
  * Absolute path of the extracted project root inside the VM — the
180
195
  * directory the user's repo lands in, with `spectest/` directly under
181
- * it. Use this (never a hard-coded path) to locate project files a
182
- * component consumes, e.g. `supabase/migrations/**`.
196
+ * it. Use this (never a hard-coded path, and never `import.meta.url` —
197
+ * that only reaches files under `spectest/`) to locate project files:
198
+ * a manifest set, `supabase/migrations/**`, a fixture.
183
199
  */
184
200
  projectRoot: string;
185
201
  /** Read a project file as UTF-8. Relative paths resolve against
186
202
  * {@link projectRoot}; absolute paths are read as-is. */
187
203
  readProjectFile(path: string): Promise<string>;
188
204
  /**
189
- * Run a command inside a service container (a raw `docker exec` — not
190
- * recorded on any test timeline, result is plain, not
191
- * provenance-wrapped). Pass an **array** for exact argv with no shell
192
- * (`["psql", "-f", "-"]`), or a **string** to run via `sh -lc`.
193
- * `opts.stdin` is piped to the process — the natural way to feed a SQL
194
- * file to `psql -f -` or a manifest to `kubectl apply -f -`.
205
+ * Run a command inside a service container. Pass an **array** for exact
206
+ * argv with no shell (`["psql", "-f", "-"]`), or a **string** to run via
207
+ * `sh -lc`. `opts.stdin` is piped to the process — the natural way to
208
+ * feed a SQL file to `psql -f -` or a manifest to `kubectl apply -f -`.
195
209
  *
196
210
  * Never throws on non-zero exit — inspect `exitCode` yourself.
211
+ *
212
+ * In a test this is the instrumented variant (recorded on the timeline,
213
+ * result {@link Wrapped}); in `setup`/`helpers` there is no timeline, so
214
+ * the result is a plain {@link ExecResult}.
215
+ */
216
+ exec(service: string, command: string | string[], opts?: ExecOpts): Promise<ExecResult>;
217
+ /**
218
+ * Instrumented `fetch`. In a test each call is recorded on the timeline
219
+ * and resolves to a {@link WrappedResponse}: reads carry provenance so
220
+ * `expect(res.status)` / `expect(await res.json())` nest under the HTTP
221
+ * call. Because the status-line accessors are {@link Carrier}s, a raw
222
+ * `res.status === 200` is a *type error* — use `res.status.unwrap()` /
223
+ * `res.unwrap().status`, or assert via `expect`. Outside a test the
224
+ * result is wrapped the same way, just with no timeline to link to.
225
+ *
226
+ * (The plain global `fetch` is wrapped the same way at runtime but keeps
227
+ * the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
228
+ * results.)
229
+ */
230
+ fetch: SpectestFetch;
231
+ /**
232
+ * Per-service helper namespaces, keyed by service name. Only services
233
+ * whose definition ships a `helpers` factory appear here; the value at
234
+ * `ctx.svc.<name>` is exactly the record that factory returned. For a
235
+ * `postgres(...)` service that ships `{ client }`, tests do
236
+ * `await ctx.svc.db.client\`SELECT 1\``.
237
+ *
238
+ * In a service-level `setup`/`helpers` hook only the service itself and
239
+ * its transitive `dependsOn` are reachable (see {@link SpectestContext}).
240
+ */
241
+ readonly svc: ServiceHandlesFor<S>;
242
+ /**
243
+ * Per-fake helper namespaces, keyed by fake name. Each is the record
244
+ * of functions the fake's `helpers` factory returned (or `{ state }`
245
+ * when it ships none — tests never touch a fake's private state
246
+ * directly). Those functions read/mutate the fake's state internally;
247
+ * the state itself is in-process and lives across the fork along with
248
+ * the rest of daemon memory, so calls in a child test see the fork's
249
+ * own copy as mutated by its ancestors. In a test every helper call is
250
+ * recorded as a step and its return value tracked, so assertions on it
251
+ * nest under the call in the timeline.
252
+ *
253
+ * Strongly typed against the project's fakes map when fakes are
254
+ * declared in `defineEnvironment({ ..., fakes })`: `ctx.fakes.stripe`
255
+ * is exactly the helpers record `defineFake`'s `helpers` factory
256
+ * returned — no cast. (Falls back to a loose record only when the
257
+ * environment declares no fakes.)
258
+ */
259
+ readonly fakes: FakeHandlesFor<F>;
260
+ /**
261
+ * Poll a predicate until it returns a truthy value, then return that
262
+ * value. In a test it records one `wait` event for the whole loop (with
263
+ * attempt count, total duration, and the description) instead of one
264
+ * event per probe — useful for "wait until pod Running"-style checks
265
+ * where the intermediate states are noise. In `setup` there's no
266
+ * timeline; it's just the loop you'd otherwise hand-roll.
267
+ *
268
+ * - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
269
+ * `intervalMs` and try again.
270
+ * - Anything else is the success value and is returned, tagged with
271
+ * the wait event's seq. Downstream `expect(...)` on it links to
272
+ * the wait (one logical step), not to N suppressed HTTP calls.
273
+ * - Throws from `fn` propagate out immediately; the wait event is
274
+ * still recorded (with `error` set) so the timeline reflects the
275
+ * abort.
276
+ * - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
277
+ *
278
+ * Side-effect calls inside `fn` (fetch, ctx.svc.* helpers, etc.)
279
+ * don't show up on the event log — the recorder is paused for the
280
+ * duration. Use `ctx.poll` for read-only observation, not for
281
+ * stateful work you want recorded.
282
+ */
283
+ poll<T>(description: string, fn: () => T | null | undefined | false | Promise<T | null | undefined | false>, opts?: {
284
+ timeoutMs?: number;
285
+ intervalMs?: number;
286
+ }): Promise<Wrapped<T>>;
287
+ /**
288
+ * Register a DNS name so the rest of this context (and anything
289
+ * downstream of it) can reach it. `{ ingress: true }` points the name at
290
+ * the daemon (a fake / TLS proxy); `{ service }` points it at a
291
+ * container's live IP; a `*.suffix` wildcard (e.g. `"*.example.com"`)
292
+ * routes a whole domain — the natural fit for k3s Ingress hosts.
293
+ *
294
+ * Answered by spectest-resolver, so it works for VM-host/test code,
295
+ * `ctx.browser()`, and peer containers (Docker forwards unknown names to
296
+ * the host resolver). It does NOT land in any container's `/etc/hosts`.
297
+ * The registration mutates in-daemon state, so from a test it's isolated
298
+ * to that test's fork (like fake state); from `setup` it's captured by
299
+ * the warm-template snapshot and every test inherits it.
300
+ *
301
+ * ```ts
302
+ * await ctx.svc.k8s.apply(ingressFor("foo.example.com"));
303
+ * await ctx.dnsName("foo.example.com", { service: "k8s" });
304
+ * const res = await ctx.fetch("http://foo.example.com");
305
+ * ```
306
+ */
307
+ dnsName(hostname: string, target: DnsTarget): Promise<void>;
308
+ /**
309
+ * Mint a leaf certificate from the in-VM root CA and return the PEMs.
310
+ *
311
+ * The value-returning counterpart to the `certificates` service field
312
+ * (see {@link ServiceConfig.certificates}): that one hands a
313
+ * certificate to a container before it boots, this one hands it to
314
+ * *you* — for loading into a Kubernetes `kubernetes.io/tls` Secret,
315
+ * posting to a control-plane API that provisions TLS endpoints, or
316
+ * driving a client-certificate handshake.
317
+ *
318
+ * The returned `ca` is the same root the whole environment already
319
+ * trusts (`ctx.fetch`, `ctx.browser()`, every service container), so a
320
+ * server configured with these PEMs verifies cleanly — no
321
+ * `rejectUnauthorized: false`, no `sslmode=require` downgrade.
322
+ * Wildcards (`*.example.com`) are allowed in `hostnames`.
323
+ *
324
+ * ```ts
325
+ * const { cert, key } = await ctx.certificate(["*.apps.test"]);
326
+ * await ctx.svc.k8s.apply(`
327
+ * apiVersion: v1
328
+ * kind: Secret
329
+ * metadata: { name: apps-tls, namespace: default }
330
+ * type: kubernetes.io/tls
331
+ * stringData:
332
+ * tls.crt: |
333
+ * ${cert.replace(/^/gm, " ")}
334
+ * tls.key: |
335
+ * ${key.replace(/^/gm, " ")}
336
+ * `);
337
+ * ```
338
+ */
339
+ certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
340
+ /**
341
+ * Start a real container on `spectest-net` at runtime — a peer machine
342
+ * with its own IP, reachable like any boot service. Returns once the
343
+ * container is up and its `readyCheck` (if any) has passed.
344
+ *
345
+ * Started from a test, the new container is part of that test's
346
+ * post-state snapshot, so a `dependsOn` child inherits it (same PID,
347
+ * same data) while siblings, which fork from the parent's earlier
348
+ * snapshot, never see it — the same isolation fake `state` and
349
+ * {@link dnsName} get. Started from `setup`, it's captured into the
350
+ * warm template and every test inherits it. Reach it by `name`
351
+ * (single-label, via the resolver) or by any `hostnames` you pass; map a
352
+ * multi-label name onto it with `ctx.dnsName(host, { service: name })`.
353
+ *
354
+ * The image is pulled on first use (fast through the host cache). See
355
+ * {@link RuntimeServiceSpec}.
356
+ *
357
+ * ```ts
358
+ * const { name } = await ctx.startService({
359
+ * name: `db-${crypto.randomUUID().slice(0, 8)}`,
360
+ * image: { type: "registry", reference: "postgres:16-alpine" },
361
+ * env: { POSTGRES_PASSWORD: "secret" },
362
+ * readyCheck: { type: "exec", command: "pg_isready -h 127.0.0.1 -p 5432" },
363
+ * });
364
+ * const sql = new Bun.SQL(`postgres://postgres:secret@${name}:5432/postgres`);
365
+ * ```
197
366
  */
198
- exec(service: string, command: string | string[], opts?: ComponentExecOpts): Promise<ExecResult>;
367
+ startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
368
+ /** Stop and remove a runtime service started via {@link startService}
369
+ * (no-op if it's already gone). */
370
+ stopService(name: string): Promise<void>;
199
371
  }
200
372
  /**
201
- * Options for {@link ComponentContext.exec} — the same set
202
- * {@link TestContext.exec} takes, deliberately aliased rather than
203
- * redeclared: the two surfaces drifted once (the component one grew
204
- * `stdin`/`timeoutMs`, the test one silently ignored them), and an alias
205
- * makes that impossible to repeat.
373
+ * @deprecated Use {@link SpectestContext} — component hooks and test
374
+ * bodies now receive the same capabilities. Kept as an alias so existing
375
+ * component code keeps compiling.
376
+ */
377
+ export type ComponentContext = SpectestContext;
378
+ /**
379
+ * Options for {@link SpectestContext.exec} — one type for every context,
380
+ * deliberately aliased rather than redeclared: the surfaces drifted once
381
+ * (the component one grew `stdin`/`timeoutMs`, the test one silently
382
+ * ignored them), and an alias makes that impossible to repeat.
206
383
  *
207
- * The one behavioural difference is the `timeoutMs` default: a component
208
- * exec runs during boot, where nothing else bounds it, so it defaults to
209
- * 120 s. A test-context exec has no default — the enclosing test's own
210
- * timeout is the ceiling there.
384
+ * The one behavioural difference is the `timeoutMs` default: a
385
+ * service-level `setup`/`helpers` exec runs during boot, where nothing
386
+ * else bounds it, so it defaults to 120 s. Elsewhere there is no default —
387
+ * in a test the enclosing test's own timeout is the ceiling, and a
388
+ * project `setup` runs unbounded (it routinely waits on rollouts).
211
389
  */
212
390
  export type ComponentExecOpts = ExecOpts;
213
- /** What a service's `setup` hook receives. */
214
- export interface ServiceSetupContext<H extends Record<string, any> = Record<string, never>> extends ComponentContext {
391
+ /** What a service's `setup` hook receives: the full
392
+ * {@link SpectestContext} plus the service's own name and helpers. */
393
+ export interface ServiceSetupContext<H extends Record<string, any> = Record<string, never>> extends SpectestContext {
215
394
  /** The service's key in the services map (container + DNS name). */
216
395
  name: string;
217
396
  /** The record the service's `helpers` factory returned (cached — setup
218
397
  * and tests share one instance), or `{}` when it ships none. */
219
398
  helpers: H;
220
399
  }
221
- /** What a service's `helpers` factory receives. */
222
- export interface ServiceHelpersContext extends ComponentContext {
400
+ /** What a service's `helpers` factory receives: the full
401
+ * {@link SpectestContext} plus the service's own name. Note `ctx.svc`
402
+ * here reaches the service's `dependsOn` only — never itself, since this
403
+ * hook is what builds that handle. */
404
+ export interface ServiceHelpersContext extends SpectestContext {
223
405
  /** The service's key in the services map (container + DNS name). */
224
406
  name: string;
225
407
  }
@@ -638,25 +820,13 @@ export type TestFn<T = void, P = undefined, S extends ServicesMap = ServicesMap,
638
820
  * `ctx.svc` only includes services with a `client` factory, each typed
639
821
  * as the factory's output (e.g. a Bun SQL pool for `postgres(...)`).
640
822
  */
641
- export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
642
- /**
643
- * Instrumented `fetch`, exposed on `ctx`. Each call is recorded on the
644
- * test timeline and resolves to a {@link WrappedResponse}: reads carry
645
- * provenance so `expect(res.status)` / `expect(await res.json())` nest
646
- * under the HTTP call. Because the status-line accessors are
647
- * {@link Carrier}s, a raw `res.status === 200` is a *type error* — use
648
- * `res.status.unwrap()` / `res.unwrap().status`, or assert via `expect`.
649
- *
650
- * (The plain global `fetch` is wrapped the same way at runtime but keeps
651
- * the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
652
- * results.)
653
- */
654
- fetch: SpectestFetch;
655
- /** Run `sh -lc <command>` inside a service container. The result is
656
- * {@link Wrapped}, so `res.stdout` is a `Carrier<string>` — assert via
657
- * `expect(res.stdout)` or recover the raw string with `res.stdout.unwrap()`.
658
- * (In `setup`/`eval` the result is wrapped too, just without a timeline
659
- * link, so `.unwrap()` works there the same way.)
823
+ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> extends Omit<SpectestContext<S, F>, "exec"> {
824
+ /** Run a command inside a service container — a string via `sh -lc`,
825
+ * or an array as exact argv. The result is {@link Wrapped}, so
826
+ * `res.stdout` is a `Carrier<string>` — assert via `expect(res.stdout)`
827
+ * or recover the raw string with `res.stdout.unwrap()`. (This is the
828
+ * one place {@link SpectestContext.exec} differs: in `setup`/`helpers`
829
+ * there's no timeline to link to, so the result is plain.)
660
830
  *
661
831
  * The full run is also captured as an asciicast and replayed in the
662
832
  * web UI — one recording per call, with output timestamped as it
@@ -671,7 +841,7 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
671
841
  * prefixing it with `cd <dir> && ` — the cwd is kept off the command
672
842
  * string, so the timeline sidebar shows just the command and the
673
843
  * directory surfaces in the detail view. */
674
- exec(service: string, command: string, opts?: ExecOpts): Promise<Wrapped<ExecResult>>;
844
+ exec(service: string, command: string | string[], opts?: ExecOpts): Promise<Wrapped<ExecResult>>;
675
845
  /**
676
846
  * Run a command inside a service container under a PTY and record the
677
847
  * full terminal session as an asciicast for replay in the web UI.
@@ -755,140 +925,6 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
755
925
  * the parent returned nothing.
756
926
  */
757
927
  readonly parent: P;
758
- /**
759
- * Per-service helper namespaces, keyed by service name. Only services
760
- * whose definition ships a `helpers` factory appear here; the value at
761
- * `ctx.svc.<name>` is exactly the record that factory returned. For a
762
- * `postgres(...)` service that ships `{ client }`, tests do
763
- * `await ctx.svc.db.client\`SELECT 1\``.
764
- */
765
- readonly svc: ServiceHandlesFor<S>;
766
- /**
767
- * Per-fake helper namespaces, keyed by fake name. Each is the record
768
- * of functions the fake's `helpers` factory returned (or `{ state }`
769
- * when it ships none — tests never touch a fake's private state
770
- * directly). Those functions read/mutate the fake's state internally;
771
- * the state itself is in-process and lives across the fork along with
772
- * the rest of daemon memory, so calls in a child test see the fork's
773
- * own copy as mutated by its ancestors. Every helper call is recorded
774
- * as a step and its return value tracked, so assertions on it nest
775
- * under the call in the timeline.
776
- *
777
- * Strongly typed against the project's fakes map when fakes are
778
- * declared in `defineEnvironment({ ..., fakes })`: `ctx.fakes.stripe`
779
- * is exactly the helpers record `defineFake`'s `helpers` factory
780
- * returned — no cast. (Falls back to a loose record only when the
781
- * environment declares no fakes.)
782
- */
783
- readonly fakes: FakeHandlesFor<F>;
784
- /**
785
- * Poll a predicate until it returns a truthy value, then return that
786
- * value. Records one `wait` event for the whole loop (with attempt
787
- * count, total duration, and the description) instead of one event
788
- * per probe — useful for "wait until pod Running"-style checks where
789
- * the intermediate states are noise.
790
- *
791
- * - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
792
- * `intervalMs` and try again.
793
- * - Anything else is the success value and is returned, tagged with
794
- * the wait event's seq. Downstream `expect(...)` on it links to
795
- * the wait (one logical step), not to N suppressed HTTP calls.
796
- * - Throws from `fn` propagate out immediately; the wait event is
797
- * still recorded (with `error` set) so the timeline reflects the
798
- * abort.
799
- * - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
800
- *
801
- * Side-effect calls inside `fn` (fetch, ctx.svc.* helpers, etc.)
802
- * don't show up on the event log — the recorder is paused for the
803
- * duration. Use `ctx.poll` for read-only observation, not for
804
- * stateful work you want recorded.
805
- */
806
- poll<T>(description: string, fn: () => T | null | undefined | false | Promise<T | null | undefined | false>, opts?: {
807
- timeoutMs?: number;
808
- intervalMs?: number;
809
- }): Promise<Wrapped<T>>;
810
- /**
811
- * Register a DNS name at runtime so the rest of this test (and anything
812
- * downstream of it) can reach it. `{ ingress: true }` points the name at
813
- * the daemon (a fake / TLS proxy); `{ service }` points it at a
814
- * container's live IP; a `*.suffix` wildcard (e.g. `"*.example.com"`)
815
- * routes a whole domain — the natural fit for k3s Ingress hosts a test
816
- * applies on the fly.
817
- *
818
- * Answered by spectest-resolver, so it works for VM-host/test code,
819
- * `ctx.browser()`, and peer containers (Docker forwards unknown names to
820
- * the host resolver). It does NOT land in any container's `/etc/hosts`.
821
- * The registration mutates in-daemon state, so it's isolated to this
822
- * test's fork — like fake state.
823
- *
824
- * ```ts
825
- * await ctx.svc.k8s.apply(ingressFor("foo.example.com"));
826
- * await ctx.dnsName("foo.example.com", { service: "k8s" });
827
- * const res = await ctx.fetch("http://foo.example.com");
828
- * ```
829
- */
830
- dnsName(hostname: string, target: DnsTarget): Promise<void>;
831
- /**
832
- * Mint a leaf certificate from the in-VM root CA and return the PEMs.
833
- *
834
- * The value-returning counterpart to the `certificates` service field
835
- * (see {@link ServiceConfig.certificates}): that one hands a
836
- * certificate to a container before it boots, this one hands it to
837
- * *you* — for loading into a Kubernetes `kubernetes.io/tls` Secret,
838
- * posting to a control-plane API that provisions TLS endpoints, or
839
- * driving a client-certificate handshake.
840
- *
841
- * The returned `ca` is the same root the whole environment already
842
- * trusts (`ctx.fetch`, `ctx.browser()`, every service container), so a
843
- * server configured with these PEMs verifies cleanly — no
844
- * `rejectUnauthorized: false`, no `sslmode=require` downgrade.
845
- * Wildcards (`*.example.com`) are allowed in `hostnames`.
846
- *
847
- * ```ts
848
- * const { cert, key } = await ctx.certificate(["*.apps.test"]);
849
- * await ctx.svc.k8s.apply(`
850
- * apiVersion: v1
851
- * kind: Secret
852
- * metadata: { name: apps-tls, namespace: default }
853
- * type: kubernetes.io/tls
854
- * stringData:
855
- * tls.crt: |
856
- * ${cert.replace(/^/gm, " ")}
857
- * tls.key: |
858
- * ${key.replace(/^/gm, " ")}
859
- * `);
860
- * ```
861
- */
862
- certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
863
- /**
864
- * Start a real container on `spectest-net` at runtime — a peer machine
865
- * with its own IP, reachable like any boot service. Returns once the
866
- * container is up and its `readyCheck` (if any) has passed.
867
- *
868
- * The new container is part of this test's post-state snapshot, so a
869
- * `dependsOn` child inherits it (same PID, same data) while siblings,
870
- * which fork from the parent's earlier snapshot, never see it — the same
871
- * isolation fake `state` and {@link dnsName} get. Reach it by `name`
872
- * (single-label, via the resolver) or by any `hostnames` you pass; map a
873
- * multi-label name onto it with `ctx.dnsName(host, { service: name })`.
874
- *
875
- * The image is pulled on first use (fast through the host cache). See
876
- * {@link RuntimeServiceSpec}.
877
- *
878
- * ```ts
879
- * const { name } = await ctx.startService({
880
- * name: `db-${crypto.randomUUID().slice(0, 8)}`,
881
- * image: { type: "registry", reference: "postgres:16-alpine" },
882
- * env: { POSTGRES_PASSWORD: "secret" },
883
- * readyCheck: { type: "exec", command: "pg_isready -h 127.0.0.1 -p 5432" },
884
- * });
885
- * const sql = new Bun.SQL(`postgres://postgres:secret@${name}:5432/postgres`);
886
- * ```
887
- */
888
- startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
889
- /** Stop and remove a runtime service started via {@link startService}
890
- * (no-op if it's already gone). */
891
- stopService(name: string): Promise<void>;
892
928
  }
893
929
  export interface ExecResult {
894
930
  stdout: string;
@@ -1103,42 +1139,16 @@ export interface Project<S extends ServicesMap = ServicesMap, F extends FakesMap
1103
1139
  tests?: TestSuite<S, F>;
1104
1140
  }
1105
1141
  /**
1106
- * Slim context handed to the project-level `setup` hook. Same shape as
1107
- * `TestContext` but pared down — no testName, no parent, no recording
1108
- * surfaces (browser, terminal, poll). If you need polling inside setup,
1109
- * write a plain loop: setup runs outside the test timeline so there's
1110
- * nothing to record into.
1142
+ * Context handed to the project-level `setup` hook: the full
1143
+ * {@link SpectestContext}, minus only what a timeline gives a test
1144
+ * (testName, parent, browser/mobile/terminal). Everything a test can do
1145
+ * to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
1146
+ * `dnsName`, `certificate`, `startService`, and project-file access via
1147
+ * `projectRoot`/`readProjectFile` — is available here too, and whatever it
1148
+ * produces is captured into the warm-template snapshot, so every test
1149
+ * inherits it without re-running.
1111
1150
  */
1112
- export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
1113
- fetch: SpectestFetch;
1114
- exec(service: string, command: string, opts?: ExecOpts): Promise<Wrapped<ExecResult>>;
1115
- readonly svc: ServiceHandlesFor<S>;
1116
- /** Same surface tests see — fakes are already up by setup time. Typed
1117
- * against the declared fakes map (see {@link TestContext.fakes}). */
1118
- readonly fakes: FakeHandlesFor<F>;
1119
- /**
1120
- * Register a DNS name (see {@link TestContext.dnsName}). Useful here to
1121
- * wire a wildcard like `"*.example.com"` to a k3s cluster once, before
1122
- * any test runs — it's captured into the warm-template snapshot, so every
1123
- * test inherits the route without re-registering.
1124
- */
1125
- dnsName(hostname: string, target: DnsTarget): Promise<void>;
1126
- /**
1127
- * Mint a leaf certificate from the in-VM root CA (see
1128
- * {@link TestContext.certificate}). Minted here it's captured into the
1129
- * warm-template snapshot, so every test inherits whatever you seeded it
1130
- * into — the fit for a cluster-wide TLS Secret applied once at setup.
1131
- */
1132
- certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
1133
- /**
1134
- * Start a runtime service (see {@link TestContext.startService}). Started
1135
- * here, it's captured into the warm-template snapshot and inherited by
1136
- * every test — use it for backing instances that should exist before any
1137
- * test runs but aren't worth a boot-time `services` entry.
1138
- */
1139
- startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
1140
- /** Stop a runtime service (see {@link TestContext.stopService}). */
1141
- stopService(name: string): Promise<void>;
1151
+ export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> extends SpectestContext<S, F> {
1142
1152
  }
1143
1153
  export type ProjectSetupFn<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> = (ctx: ProjectSetupContext<S, F>) => void | Promise<void>;
1144
1154
  /**
@@ -1240,6 +1250,30 @@ export interface DefinedEnvironment<S extends ServicesMap, F extends FakesMap =
1240
1250
  * matchers like `.toBe(...)` disappear.
1241
1251
  */
1242
1252
  export type Ctx<E> = E extends DefinedEnvironment<infer S, infer F> ? TestContext<unknown, S, F> : never;
1253
+ /**
1254
+ * The `ctx` type a `setup` hook receives for an environment — the
1255
+ * {@link Ctx} counterpart for bring-up code. Use it to type the deploy /
1256
+ * seed helpers a project's `setup` calls, instead of hand-rolling a
1257
+ * structural `{ exec, certificate }` interface:
1258
+ *
1259
+ * ```ts
1260
+ * const env = defineEnvironment({ ... });
1261
+ * export type AppSetupCtx = SetupCtx<typeof env>;
1262
+ *
1263
+ * async function deployPlatform(ctx: AppSetupCtx) {
1264
+ * const manifests = await ctx.readProjectFile("deploy/platform.yaml");
1265
+ * const { cert, key } = await ctx.certificate(["*.apps.test"]);
1266
+ * await ctx.svc.k8s.apply(manifests);
1267
+ * }
1268
+ * ```
1269
+ *
1270
+ * A service-level `setup`/`helpers` hook gets the same capabilities plus
1271
+ * its own `name`/`helpers` — see {@link ServiceSetupContext}. Since
1272
+ * {@link ProjectSetupContext} is a {@link SpectestContext}, a helper typed
1273
+ * with `SetupCtx` also accepts a service `setup` ctx, so one function can
1274
+ * serve both.
1275
+ */
1276
+ export type SetupCtx<E> = E extends DefinedEnvironment<infer S, infer F> ? ProjectSetupContext<S, F> : never;
1243
1277
  /**
1244
1278
  * Define an environment and get back a builder you can hang tests off.
1245
1279
  * The builder's `.test(...)` returns test cases typed against the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",