@specific.dev/spectest 0.107.2 → 0.108.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/src/recorder.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // current recorder is `null` and the `record*` helpers are no-ops, so
9
9
  // callers can invoke them unconditionally.
10
10
 
11
+ import { AsyncLocalStorage } from "node:async_hooks";
11
12
  import type { AndroidStepDetails } from "./android-step.js";
12
13
  import { clearPendingNullish } from "./inspect.js";
13
14
  import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
@@ -27,6 +28,7 @@ export type TestEvent =
27
28
  | TerminalStepEvent
28
29
  | WaitEvent
29
30
  | FakeEvent
31
+ | ServiceHelperEvent
30
32
  | EnvEvent
31
33
  | EmailEvent
32
34
  // Open `kind`, so it goes last: narrowing the union by a literal kind
@@ -339,6 +341,14 @@ export interface FakeEvent extends BaseEvent {
339
341
  error?: string;
340
342
  }
341
343
 
344
+ /** An annotated service helper has the same call/return shape as a fake.
345
+ * Only helpers returning an annotation emit this event. */
346
+ export interface ServiceHelperEvent extends Omit<FakeEvent, "kind" | "fake"> {
347
+ kind: "service";
348
+ /** Service name — the key in `ctx.svc`. */
349
+ service: string;
350
+ }
351
+
342
352
  /**
343
353
  * One captured email, as embedded on an {@link EmailEvent}. The HTML and
344
354
  * text bodies ride along (truncated to the output cap) so the dashboard
@@ -631,7 +641,12 @@ export interface EventReservation {
631
641
  }
632
642
 
633
643
  class Recorder {
634
- private events: TestEvent[] = [];
644
+ private rootEvents: TestEvent[] = [];
645
+ readonly pendingHelpers = new Set<RecordingBranch>();
646
+ private get events(): TestEvent[] {
647
+ const branch = recordingBranches.getStore();
648
+ return branch?.owner === this ? branchEvents(branch) : this.rootEvents;
649
+ }
635
650
  private seq = 0;
636
651
  constructor(private readonly start = Date.now()) {}
637
652
 
@@ -733,6 +748,60 @@ class Recorder {
733
748
 
734
749
  let current: Recorder | null = null;
735
750
 
751
+ interface RecordingBranch {
752
+ owner: Recorder;
753
+ events: TestEvent[];
754
+ parent: RecordingBranch | undefined;
755
+ destination: TestEvent[];
756
+ state: "open" | "kept" | "discarded";
757
+ }
758
+
759
+ const recordingBranches = new AsyncLocalStorage<RecordingBranch>();
760
+
761
+ function branchEvents(branch: RecordingBranch): TestEvent[] {
762
+ return branch.state === "kept"
763
+ ? branch.parent ? branchEvents(branch.parent) : branch.destination
764
+ : branch.events;
765
+ }
766
+
767
+ function branchAllowsRecording(branch: RecordingBranch | undefined): boolean {
768
+ return !branch || (branch.owner === current && branch.state !== "discarded" &&
769
+ branchAllowsRecording(branch.parent));
770
+ }
771
+
772
+ /** Buffer a helper's async descendants until its return chooses between the
773
+ * implementation's events and one annotated helper call. Seqs still come from
774
+ * the test recorder, so concurrent calls and linked assertions stay unique.
775
+ * Poll truncation/grouping inside the helper applies only to its own buffer. */
776
+ export function captureRecording(): {
777
+ run<T>(fn: () => T): T;
778
+ finish(keep: boolean): void;
779
+ isCurrent(): boolean;
780
+ } {
781
+ if (!active()) return { run: (fn) => fn(), finish: () => {}, isCurrent: () => false };
782
+ const branch: RecordingBranch = {
783
+ owner: current!, events: [], parent: recordingBranches.getStore(),
784
+ destination: current!.drain(), state: "open",
785
+ };
786
+ current!.pendingHelpers.add(branch);
787
+ return {
788
+ run: (fn) => recordingBranches.run(branch, fn),
789
+ isCurrent: () => branch.owner === current && active(),
790
+ finish(keep) {
791
+ if (branch.state !== "open") return;
792
+ branch.owner.pendingHelpers.delete(branch);
793
+ if (keep && branchAllowsRecording(branch)) {
794
+ const destination = branch.parent ? branchEvents(branch.parent) : branch.destination;
795
+ for (const event of branch.events) destination.push(event);
796
+ branch.state = "kept";
797
+ } else {
798
+ branch.state = "discarded";
799
+ }
800
+ branch.events.length = 0;
801
+ },
802
+ };
803
+ }
804
+
736
805
  // Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
737
806
  // (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
738
807
  // suppressed until its own resume balances out). Active record* sites
@@ -751,7 +820,7 @@ export function resumeRecording(): void {
751
820
  }
752
821
 
753
822
  function active(): boolean {
754
- return current !== null && paused === 0 && scopeAllowsRecording();
823
+ return current !== null && paused === 0 && scopeAllowsRecording() && branchAllowsRecording(recordingBranches.getStore());
755
824
  }
756
825
 
757
826
  export function startRecording(startedAt = Date.now()): void {
@@ -762,6 +831,18 @@ export function startRecording(startedAt = Date.now()): void {
762
831
  export function stopRecording(): TestEvent[] {
763
832
  if (!current) return [];
764
833
  const evs = current.drain();
834
+ // A timeout can stop the test before a helper returns. Keep its available
835
+ // diagnostics, then fence detached work out of any subsequent recording.
836
+ for (const branch of current.pendingHelpers) {
837
+ if (branchAllowsRecording(branch)) {
838
+ for (const event of branch.events) evs.push(event);
839
+ }
840
+ }
841
+ for (const branch of current.pendingHelpers) {
842
+ branch.events.length = 0;
843
+ branch.state = "discarded";
844
+ }
845
+ current.pendingHelpers.clear();
765
846
  current = null;
766
847
  paused = 0;
767
848
  return evs;
@@ -862,6 +943,13 @@ export function recordFake(
862
943
  return active() ? current!.push({ kind: "fake", ...ev }, reservation) : undefined;
863
944
  }
864
945
 
946
+ export function recordServiceHelper(
947
+ ev: Omit<ServiceHelperEvent, "seq" | "tOffsetMs" | "kind">,
948
+ reservation?: EventReservation,
949
+ ): number | undefined {
950
+ return active() ? current!.push({ kind: "service", ...ev }, reservation) : undefined;
951
+ }
952
+
865
953
  export function recordEnv(
866
954
  ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">,
867
955
  reservation?: EventReservation,
@@ -992,11 +1080,7 @@ export function recordWait(
992
1080
  ev: Omit<WaitEvent, "seq" | "tOffsetMs" | "kind">,
993
1081
  reservation?: EventReservation,
994
1082
  ): number | undefined {
995
- // Use `current` directly (not `active()`) so a `recordWait` at the
996
- // end of a `ctx.poll` block lands even though the surrounding code
997
- // just resumed from `paused`. Pushing this event is the whole point
998
- // of the poll primitive.
999
- return current?.push({ kind: "wait", ...ev }, reservation);
1083
+ return active() ? current!.push({ kind: "wait", ...ev }, reservation) : undefined;
1000
1084
  }
1001
1085
 
1002
1086
  /** Number of events the active recorder has accumulated. Returns 0