@specific.dev/spectest 0.49.0 → 0.51.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/annotate.d.ts +52 -2
- package/dist/annotate.js +46 -0
- package/dist/browser.d.ts +1 -0
- package/dist/daemon.js +153 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +17 -7
- package/dist/locator.d.ts +10 -2
- package/dist/locator.js +63 -4
- package/dist/recorder.d.ts +78 -1
- package/dist/recorder.js +16 -3
- package/package.json +1 -1
- package/src/annotate.ts +104 -1
- package/src/browser.ts +1 -0
- package/src/daemon.ts +172 -16
- package/src/index.ts +20 -7
- package/src/locator.ts +84 -8
- package/src/recorder.test.ts +57 -0
- package/src/recorder.ts +79 -4
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
recordEmail,
|
|
5
|
+
recordFake,
|
|
6
|
+
recordWait,
|
|
7
|
+
recorderEventCount,
|
|
8
|
+
recorderMarkChildren,
|
|
9
|
+
reserveEvent,
|
|
10
|
+
startRecording,
|
|
11
|
+
stopRecording,
|
|
12
|
+
} from "./recorder.js";
|
|
13
|
+
|
|
14
|
+
describe("markChildren", () => {
|
|
15
|
+
test("groups a poll's kept iteration under the wait", () => {
|
|
16
|
+
startRecording();
|
|
17
|
+
const from = recorderEventCount();
|
|
18
|
+
recordFake({ fake: "resend", member: "lastEmailTo", args: [], durationMs: 1 });
|
|
19
|
+
const wait = recordWait({ description: "the mail arrives", attempts: 1, durationMs: 2, passed: true })!;
|
|
20
|
+
recorderMarkChildren(from, wait);
|
|
21
|
+
const events = stopRecording();
|
|
22
|
+
|
|
23
|
+
const fake = events.find((e) => e.kind === "fake")!;
|
|
24
|
+
expect(fake.parentSeq).toBe(wait);
|
|
25
|
+
// The wait never becomes its own child.
|
|
26
|
+
expect(events.find((e) => e.kind === "wait")!.parentSeq).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// A fake helper's `annotate(...)` child is that call's value, so it stays a
|
|
30
|
+
// child of the FAKE event even when the call happened inside a poll
|
|
31
|
+
// predicate. Overwriting it with the wait's seq made the wait itself render
|
|
32
|
+
// as the email, with the fake call showing raw JSON below it.
|
|
33
|
+
test("keeps an already-grouped event under its own parent", () => {
|
|
34
|
+
startRecording();
|
|
35
|
+
const from = recorderEventCount();
|
|
36
|
+
// The call's own slot is reserved first, exactly as invokeFakeHelper does.
|
|
37
|
+
const resv = reserveEvent();
|
|
38
|
+
const fakeSeq = recordFake(
|
|
39
|
+
{ fake: "resend", member: "lastEmailTo", args: [], durationMs: 1 },
|
|
40
|
+
resv,
|
|
41
|
+
)!;
|
|
42
|
+
recordEmail({
|
|
43
|
+
parentSeq: fakeSeq,
|
|
44
|
+
annotation: true,
|
|
45
|
+
service: "resend",
|
|
46
|
+
op: "lastEmailTo",
|
|
47
|
+
message: { subject: "Ditt formulär" },
|
|
48
|
+
durationMs: 1,
|
|
49
|
+
});
|
|
50
|
+
const wait = recordWait({ description: "the mail arrives", attempts: 1, durationMs: 2, passed: true })!;
|
|
51
|
+
recorderMarkChildren(from, wait);
|
|
52
|
+
const events = stopRecording();
|
|
53
|
+
|
|
54
|
+
expect(events.find((e) => e.kind === "fake")!.parentSeq).toBe(wait);
|
|
55
|
+
expect(events.find((e) => e.kind === "email")!.parentSeq).toBe(fakeSeq);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/recorder.ts
CHANGED
|
@@ -26,7 +26,11 @@ export type TestEvent =
|
|
|
26
26
|
| WaitEvent
|
|
27
27
|
| FakeEvent
|
|
28
28
|
| EnvEvent
|
|
29
|
-
| EmailEvent
|
|
29
|
+
| EmailEvent
|
|
30
|
+
// Open `kind`, so it goes last: narrowing the union by a literal kind
|
|
31
|
+
// still reaches the specific member above, and this one carries anything
|
|
32
|
+
// a component describes in presentation terms.
|
|
33
|
+
| StepEvent;
|
|
30
34
|
|
|
31
35
|
interface BaseEvent {
|
|
32
36
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
@@ -469,6 +473,15 @@ export interface BrowserEvent extends BaseEvent {
|
|
|
469
473
|
dy?: number;
|
|
470
474
|
x?: number;
|
|
471
475
|
y?: number;
|
|
476
|
+
/**
|
|
477
|
+
* rrweb node id of the element a settled `expect(locator)` step asserted on
|
|
478
|
+
* — the element's identity in this session's recording. Playwright calls an
|
|
479
|
+
* element below the fold visible, so the frame the step seeks to need not
|
|
480
|
+
* contain it; with this the viewer finds the node in the replayed DOM and
|
|
481
|
+
* scrolls it into view. Absent when the SDK couldn't measure it (an element
|
|
482
|
+
* in a frame, one rrweb hasn't serialized, a matcher whose subject is gone).
|
|
483
|
+
*/
|
|
484
|
+
targetNodeId?: number;
|
|
472
485
|
/** Screenshot image format. */
|
|
473
486
|
format?: string;
|
|
474
487
|
/** For `waitFor`: how many times the predicate was polled. */
|
|
@@ -637,13 +650,22 @@ class Recorder {
|
|
|
637
650
|
}
|
|
638
651
|
}
|
|
639
652
|
|
|
640
|
-
/** Stamp `parentSeq` onto every event at index >= `startIdx
|
|
641
|
-
* by `ctx.poll` to group the
|
|
642
|
-
* resulting wait event.
|
|
653
|
+
/** Stamp `parentSeq` onto every event at index >= `startIdx` that isn't
|
|
654
|
+
* already grouped under something else. Used by `ctx.poll` to group the
|
|
655
|
+
* kept iteration's events under the resulting wait event.
|
|
656
|
+
*
|
|
657
|
+
* An event that already carries a `parentSeq` keeps it: it belongs to a
|
|
658
|
+
* step *inside* this one, and re-parenting it here would flatten the
|
|
659
|
+
* nesting the UI renders from. The case that made this matter is a fake
|
|
660
|
+
* helper called in a poll predicate — its `annotate(...)` child is the
|
|
661
|
+
* fake call's value, and stamping the wait's seq over it made the WAIT
|
|
662
|
+
* render as an email/chat while the fake call showed raw JSON. Its
|
|
663
|
+
* ancestor is still the wait, one level up through the fake event. */
|
|
643
664
|
markChildren(startIdx: number, parentSeq: number): void {
|
|
644
665
|
for (let i = startIdx; i < this.events.length; i++) {
|
|
645
666
|
const ev = this.events[i]!;
|
|
646
667
|
if (ev.seq === parentSeq) continue;
|
|
668
|
+
if (ev.parentSeq !== undefined) continue;
|
|
647
669
|
ev.parentSeq = parentSeq;
|
|
648
670
|
}
|
|
649
671
|
}
|
|
@@ -819,6 +841,59 @@ export function recordEnv(
|
|
|
819
841
|
return active() ? current!.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
820
842
|
}
|
|
821
843
|
|
|
844
|
+
/**
|
|
845
|
+
* One block of step detail, in the dashboard's presentation vocabulary
|
|
846
|
+
* (`crates/control-plane/src/web/blocks.rs`, which owns the closed set).
|
|
847
|
+
* A server that predates a block type skips it rather than failing, so a
|
|
848
|
+
* newer SDK's step degrades to the blocks the server knows.
|
|
849
|
+
*/
|
|
850
|
+
export type StepBlock =
|
|
851
|
+
| { type: "text"; text: string }
|
|
852
|
+
| { type: "code"; code: string; lang?: string; label?: string }
|
|
853
|
+
| { type: "json"; value: unknown; label?: string }
|
|
854
|
+
| { type: "kv"; rows: { label: string; value?: string; error?: boolean }[] }
|
|
855
|
+
| { type: "table"; columns: string[]; rows: unknown[][] }
|
|
856
|
+
| { type: "htmlSandbox"; html: string; label?: string }
|
|
857
|
+
| { type: "chat"; messages: ChatBlockMessage[]; label?: string };
|
|
858
|
+
|
|
859
|
+
/** One message of a `chat` block. */
|
|
860
|
+
export interface ChatBlockMessage {
|
|
861
|
+
/** Who sent it. The transcript is drawn as the end user would see it, so
|
|
862
|
+
* `self` (the end user's own messages) sits on the right and `other` on
|
|
863
|
+
* the left. */
|
|
864
|
+
side?: "self" | "other";
|
|
865
|
+
text?: string;
|
|
866
|
+
/** Arrived in *this* step: drawn with an entrance animation. Only the
|
|
867
|
+
* producer knows this, so only the producer sets it. */
|
|
868
|
+
new?: boolean;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* A step that describes itself in presentation terms — a title plus an
|
|
873
|
+
* ordered list of blocks — instead of as one of the recorder's hard-coded
|
|
874
|
+
* kinds. This is the seam that lets a component ship a new kind of step
|
|
875
|
+
* with no server change: `kind` stays an opaque grouping/diff-alignment
|
|
876
|
+
* label and never decides how the step draws.
|
|
877
|
+
*/
|
|
878
|
+
export interface StepEvent extends BaseEvent {
|
|
879
|
+
/** Opaque. Groups the step and aligns it across runs; never matched on
|
|
880
|
+
* to pick a renderer. */
|
|
881
|
+
kind: string;
|
|
882
|
+
/** The step's one-line summary in the timeline. */
|
|
883
|
+
title: string;
|
|
884
|
+
blocks?: StepBlock[];
|
|
885
|
+
status?: "passed" | "failed" | "error";
|
|
886
|
+
durationMs?: number;
|
|
887
|
+
error?: string;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export function recordStep(
|
|
891
|
+
ev: Omit<StepEvent, "seq" | "tOffsetMs">,
|
|
892
|
+
reservation?: EventReservation,
|
|
893
|
+
): number | undefined {
|
|
894
|
+
return active() ? current!.push(ev, reservation) : undefined;
|
|
895
|
+
}
|
|
896
|
+
|
|
822
897
|
export function recordEmail(
|
|
823
898
|
ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
|
|
824
899
|
reservation?: EventReservation,
|