@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/README.md +23 -0
- package/dist/annotate.d.ts +15 -14
- package/dist/annotate.js +12 -34
- package/dist/daemon.js +9 -146
- package/dist/helper-tracking.d.ts +7 -0
- package/dist/helper-tracking.js +142 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +3 -7
- package/dist/recorder.d.ts +18 -1
- package/dist/recorder.js +67 -7
- package/package.json +1 -1
- package/src/annotate.ts +22 -45
- package/src/daemon.ts +8 -168
- package/src/helper-tracking.test.ts +289 -0
- package/src/helper-tracking.ts +162 -0
- package/src/index.ts +28 -14
- package/src/recorder.ts +91 -7
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
|
|
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
|
-
|
|
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
|