@specific.dev/spectest 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -46,7 +46,7 @@ import { conflict, notFound, requireString, } from "./harness/methods.js";
46
46
  import { openTerminal } from "./terminal.js";
47
47
  import { readAnnotation } from "./annotate.js";
48
48
  import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
49
- import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
49
+ import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
50
50
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
51
51
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
52
52
  function namedServices(cfg) {
@@ -3074,17 +3074,41 @@ const TEST_DATA = new Map();
3074
3074
  * test timeout instead, and a project `setup` — which routinely waits on
3075
3075
  * rollouts — stays unbounded, so neither gets a default. */
3076
3076
  const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
3077
- /** Raw `docker exec` with optional piped stdin. Array command = exact
3078
- * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
3079
- * `exitCode`, never thrown. Unlike the test-context `exec` this records
3080
- * nothing — setup/helpers-factory time has no test timeline. */
3077
+ /** The running test case's recording `exec`, installed by `runOne`
3078
+ * for the length of the case and cleared again in its `finally`. It is
3079
+ * read at *call* time, which is the whole point: a component's context —
3080
+ * a `helpers` factory's, a service `setup` hook's — is built once during
3081
+ * bootstrap and cached, so the `exec` those hooks destructure can never
3082
+ * be the test's own. Without this, everything a service helper did was
3083
+ * invisible: `ctx.svc.<name>.<helper>()` shelling into a container
3084
+ * recorded no step, and a helper called inside a `ctx.poll` predicate
3085
+ * left the wait with nothing to render inside it (the poll keeps the
3086
+ * events of its last attempt — there were none). Undefined outside a
3087
+ * test, so bootstrap and project setup stay uninstrumented. */
3088
+ let RECORDING_EXEC;
3089
+ /** `docker exec` with optional piped stdin. Array command = exact argv
3090
+ * (no shell); string = `sh -lc`. Non-zero exit is reported via
3091
+ * `exitCode`, never thrown.
3092
+ *
3093
+ * Records a step when a test is running (through `RECORDING_EXEC`, which
3094
+ * also captures the asciicast), and nothing at bootstrap. Either way the
3095
+ * caller gets the PLAIN `ExecResult`, never the test context's
3096
+ * provenance-wrapped one: component code is ordinary code written against
3097
+ * the declared type, and a wrapper is an object — `res.exitCode !== 0`
3098
+ * would be true for every exit code, and a client branching on `typeof`
3099
+ * would take the wrong arm. Provenance has nothing to point at here
3100
+ * anyway, since the helper's return value is not what the step recorded. */
3081
3101
  function componentExec(service, command, opts, defaultTimeoutMs) {
3082
3102
  assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3083
3103
  const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs;
3084
- return execInService(service, command, {
3104
+ const merged = {
3085
3105
  ...opts,
3086
3106
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3087
- });
3107
+ };
3108
+ const recording = RECORDING_EXEC;
3109
+ if (recording)
3110
+ return recording(service, command, merged).then(readRaw);
3111
+ return execInService(service, command, merged);
3088
3112
  }
3089
3113
  /** Transitive `dependsOn` closure of a service, by name. */
3090
3114
  function transitiveDeps(service) {
@@ -3918,6 +3942,12 @@ async function runOne(testCase) {
3918
3942
  testName: testCase.name,
3919
3943
  parent,
3920
3944
  };
3945
+ // Let a component's `exec` record for the length of this case. The
3946
+ // context a `helpers` factory or a service `setup` hook holds was built
3947
+ // at bootstrap and carries `componentExec`, which reads this at call
3948
+ // time — so from here on a service helper's `docker exec` lands on the
3949
+ // timeline (and in the cast) exactly like the test's own `ctx.exec`.
3950
+ RECORDING_EXEC = recordedExec;
3921
3951
  const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
3922
3952
  let timer;
3923
3953
  const timedOut = new Promise((_, reject) => {
@@ -3946,6 +3976,7 @@ async function runOne(testCase) {
3946
3976
  finally {
3947
3977
  if (timer)
3948
3978
  clearTimeout(timer);
3979
+ RECORDING_EXEC = undefined;
3949
3980
  restoreFetch();
3950
3981
  restoreConsole();
3951
3982
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
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
@@ -134,7 +134,7 @@ import {
134
134
  type TestEvent,
135
135
  type OmittedBody,
136
136
  } from "./recorder.js";
137
- import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
137
+ import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
138
138
  import type { Wrapped, WrappedResponse } from "./inspect.js";
139
139
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
140
140
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
@@ -3773,10 +3773,37 @@ const TEST_DATA = new Map<string, unknown>();
3773
3773
  * rollouts — stays unbounded, so neither gets a default. */
3774
3774
  const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
3775
3775
 
3776
- /** Raw `docker exec` with optional piped stdin. Array command = exact
3777
- * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
3778
- * `exitCode`, never thrown. Unlike the test-context `exec` this records
3779
- * nothing — setup/helpers-factory time has no test timeline. */
3776
+ /** The running test case's recording `exec`, installed by `runOne`
3777
+ * for the length of the case and cleared again in its `finally`. It is
3778
+ * read at *call* time, which is the whole point: a component's context —
3779
+ * a `helpers` factory's, a service `setup` hook's — is built once during
3780
+ * bootstrap and cached, so the `exec` those hooks destructure can never
3781
+ * be the test's own. Without this, everything a service helper did was
3782
+ * invisible: `ctx.svc.<name>.<helper>()` shelling into a container
3783
+ * recorded no step, and a helper called inside a `ctx.poll` predicate
3784
+ * left the wait with nothing to render inside it (the poll keeps the
3785
+ * events of its last attempt — there were none). Undefined outside a
3786
+ * test, so bootstrap and project setup stay uninstrumented. */
3787
+ let RECORDING_EXEC:
3788
+ | ((
3789
+ service: string,
3790
+ command: string | string[],
3791
+ opts?: ExecOpts,
3792
+ ) => Promise<ExecResult>)
3793
+ | undefined;
3794
+
3795
+ /** `docker exec` with optional piped stdin. Array command = exact argv
3796
+ * (no shell); string = `sh -lc`. Non-zero exit is reported via
3797
+ * `exitCode`, never thrown.
3798
+ *
3799
+ * Records a step when a test is running (through `RECORDING_EXEC`, which
3800
+ * also captures the asciicast), and nothing at bootstrap. Either way the
3801
+ * caller gets the PLAIN `ExecResult`, never the test context's
3802
+ * provenance-wrapped one: component code is ordinary code written against
3803
+ * the declared type, and a wrapper is an object — `res.exitCode !== 0`
3804
+ * would be true for every exit code, and a client branching on `typeof`
3805
+ * would take the wrong arm. Provenance has nothing to point at here
3806
+ * anyway, since the helper's return value is not what the step recorded. */
3780
3807
  function componentExec(
3781
3808
  service: string,
3782
3809
  command: string | string[],
@@ -3785,10 +3812,13 @@ function componentExec(
3785
3812
  ): Promise<ExecResult> {
3786
3813
  assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3787
3814
  const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs;
3788
- return execInService(service, command, {
3815
+ const merged = {
3789
3816
  ...opts,
3790
3817
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3791
- });
3818
+ };
3819
+ const recording = RECORDING_EXEC;
3820
+ if (recording) return recording(service, command, merged).then(readRaw);
3821
+ return execInService(service, command, merged);
3792
3822
  }
3793
3823
 
3794
3824
  // ────────────────────────────────────────────────────────────────────────
@@ -4796,6 +4826,13 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
4796
4826
  parent,
4797
4827
  };
4798
4828
 
4829
+ // Let a component's `exec` record for the length of this case. The
4830
+ // context a `helpers` factory or a service `setup` hook holds was built
4831
+ // at bootstrap and carries `componentExec`, which reads this at call
4832
+ // time — so from here on a service helper's `docker exec` lands on the
4833
+ // timeline (and in the cast) exactly like the test's own `ctx.exec`.
4834
+ RECORDING_EXEC = recordedExec;
4835
+
4799
4836
  const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
4800
4837
  let timer: NodeJS.Timeout | undefined;
4801
4838
  const timedOut = new Promise<never>((_, reject) => {
@@ -4825,6 +4862,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
4825
4862
  };
4826
4863
  } finally {
4827
4864
  if (timer) clearTimeout(timer);
4865
+ RECORDING_EXEC = undefined;
4828
4866
  restoreFetch();
4829
4867
  restoreConsole();
4830
4868
  // eslint-disable-next-line @typescript-eslint/no-explicit-any