@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.
@@ -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`. Used
641
- * by `ctx.poll` to group the kept iteration's events under 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,