@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.
@@ -1,4 +1,4 @@
1
- import { type EmailEventMessage, type EmailEventSummary } from "./recorder.js";
1
+ import { type EmailEventMessage, type EmailEventSummary, type StepBlock } from "./recorder.js";
2
2
  /** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
3
3
  * box can't be read as the value by accident inside the fake, and the
4
4
  * helper types can recognise it and hand the *test* back a plain `T`. */
@@ -25,6 +25,9 @@ export interface AnnotationOptions {
25
25
  /** Draw the value as an email — one message, or a list of them for a
26
26
  * mailbox listing. See {@link EmailAnnotation}. */
27
27
  email: EmailAnnotation | readonly EmailAnnotation[];
28
+ /** Draw the value as a chat transcript — bubbles by role, with the
29
+ * messages that arrived in this call marked. See {@link ChatAnnotation}. */
30
+ chat: ChatAnnotation | readonly ChatMessageAnnotation[];
28
31
  }
29
32
  /** The kinds {@link annotate} accepts. */
30
33
  export type AnnotationKind = keyof AnnotationOptions;
@@ -34,7 +37,8 @@ export type AnnotationKind = keyof AnnotationOptions;
34
37
  * which needn't be the annotation kind's name, since an annotation names
35
38
  * what the value *is* and the event names how it's drawn.
36
39
  */
37
- export type RenderAnnotation = {
40
+ export type RenderAnnotation = EmailRenderAnnotation | ChatRenderAnnotation;
41
+ type EmailRenderAnnotation = {
38
42
  kind: "email";
39
43
  /** Single-message ops. */
40
44
  message?: EmailEventMessage;
@@ -43,6 +47,14 @@ export type RenderAnnotation = {
43
47
  /** Total matches (a listing is capped on the event, never in the value). */
44
48
  count?: number;
45
49
  };
50
+ /** A chat lowers to a step that describes itself in presentation terms — a
51
+ * title plus blocks — rather than to a kind of its own: `blocks.rs` owns
52
+ * the drawing vocabulary, and a transcript is one of its block types. */
53
+ type ChatRenderAnnotation = {
54
+ kind: "chat";
55
+ title?: string;
56
+ blocks: StepBlock[];
57
+ };
46
58
  /**
47
59
  * The email fields to render. Every one is optional — pass what the fake
48
60
  * has. Addresses take a single string or a list.
@@ -64,6 +76,44 @@ export interface EmailAnnotation {
64
76
  * "Plain-text version" disclosure when there is. */
65
77
  text?: string;
66
78
  }
79
+ /**
80
+ * A conversation to draw as chat bubbles.
81
+ *
82
+ * Two sides and their text, which is all a transcript needs to read as one.
83
+ * It is drawn from the end user's point of view — their own messages on the
84
+ * right, the other party's on the left.
85
+ * Pass the messages alone (`annotate(v, "chat", messages)`), or this object
86
+ * when the thread has an identity worth showing. With no options at all the
87
+ * value itself is read as the message list.
88
+ */
89
+ export interface ChatAnnotation {
90
+ messages: readonly ChatMessageAnnotation[];
91
+ /**
92
+ * The thread's identity — a phone number, a channel, whoever is on the
93
+ * other end — shown as a header over the bubbles. Optional: a fake with
94
+ * one conversation has nothing useful to put here, and the transcript
95
+ * reads fine without it.
96
+ */
97
+ title?: string;
98
+ }
99
+ /** One turn of a {@link ChatAnnotation}. */
100
+ export interface ChatMessageAnnotation {
101
+ /**
102
+ * Who sent it. A transcript is drawn the way the **end user** would see
103
+ * it on their own screen, so `self` is the end user's own messages — the
104
+ * person using the app under test — and sits on the right; `other` is
105
+ * whoever they are talking to and sits on the left.
106
+ */
107
+ side: "self" | "other";
108
+ text: string;
109
+ /**
110
+ * This message arrived in *this* call: it slides in when the step opens,
111
+ * and again whenever it is reopened. Only the fake knows which turns are new —
112
+ * the dashboard sees one call, not the conversation's history — so it is
113
+ * the fake that marks them.
114
+ */
115
+ new?: boolean;
116
+ }
67
117
  /**
68
118
  * Say what a fake helper's return value *is*, so the timeline can draw it
69
119
  * as that on top of the JSON it always shows. The call stays an ordinary
package/dist/annotate.js CHANGED
@@ -82,6 +82,8 @@ function lower(kind, options, value) {
82
82
  switch (kind) {
83
83
  case "email":
84
84
  return lowerEmail(options, value);
85
+ case "chat":
86
+ return lowerChat(options, value);
85
87
  default:
86
88
  // Unreachable while `kind` is a key of AnnotationOptions; a kind added
87
89
  // to that interface without a case here lands on this line.
@@ -129,6 +131,50 @@ function fields(v) {
129
131
  ? v
130
132
  : {};
131
133
  }
134
+ const NO_MESSAGES = 'annotate(value, "chat"): nothing to render — no messages found on the ' +
135
+ "value. Pass them explicitly, e.g. " +
136
+ 'annotate(value, "chat", value.turns.map((t) => ({ side: t.fromCustomer ? "self" : "other", text: t.body }))).';
137
+ function lowerChat(options, value) {
138
+ const source = deepUnwrap(options ?? value);
139
+ let list;
140
+ let title;
141
+ if (Array.isArray(source)) {
142
+ list = source;
143
+ }
144
+ else {
145
+ const bag = fields(source);
146
+ list = bag.messages;
147
+ title = text(bag.title) || undefined;
148
+ }
149
+ // An empty conversation is a real state (nothing said yet) and renders as
150
+ // one; a value with no `messages` at all is the author's mistake.
151
+ if (!Array.isArray(list))
152
+ throw new Error(NO_MESSAGES);
153
+ const messages = list.map((m) => chatMessageOf(fields(m)));
154
+ // The title travels twice on purpose: as the step's own summary, and as
155
+ // the transcript's header — the annotated view renders the blocks alone,
156
+ // so a title carried only on the step would never be seen.
157
+ return {
158
+ kind: "chat",
159
+ title,
160
+ blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
161
+ };
162
+ }
163
+ function chatMessageOf(src) {
164
+ const msg = {};
165
+ // Anything that isn't the end user's own message is drawn as the other
166
+ // party's, rather than a turn being dropped for a typo.
167
+ if (text(src.side) === "self")
168
+ msg.side = "self";
169
+ else
170
+ msg.side = "other";
171
+ const body = text(src.text);
172
+ if (body)
173
+ msg.text = truncateUtf8(body).value;
174
+ if (src.new === true)
175
+ msg.new = true;
176
+ return msg;
177
+ }
132
178
  function messageOf(src) {
133
179
  const msg = {};
134
180
  const from = text(src.from);
package/dist/browser.d.ts CHANGED
@@ -360,4 +360,5 @@ export interface RecordableFields {
360
360
  artifactId: string;
361
361
  attribute: string;
362
362
  files: string[];
363
+ targetNodeId: number;
363
364
  }
package/dist/daemon.js CHANGED
@@ -45,7 +45,7 @@ import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToke
45
45
  import { conflict, notFound, requireString, } from "./harness/methods.js";
46
46
  import { openTerminal } from "./terminal.js";
47
47
  import { readAnnotation } from "./annotate.js";
48
- import { recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
48
+ import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
49
49
  import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
50
50
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
51
51
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
@@ -829,13 +829,126 @@ async function prepareServiceImage(svc, opts) {
829
829
  }
830
830
  return buildServiceImage(svc.name, image, tag);
831
831
  }
832
+ /**
833
+ * What the folded CA step prints when it could not write the trust store
834
+ * at all — the one outcome that still needs {@link ensureCaTrustedImage},
835
+ * which can take root for the write.
836
+ *
837
+ * The step assembles this prefix from a shell variable so that the marker
838
+ * appears ONLY in the step's output. BuildKit echoes each instruction into
839
+ * the same log verbatim, so a marker written literally in the RUN would
840
+ * match on every build whether or not the step ever printed it.
841
+ */
842
+ const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
843
+ /**
844
+ * The CA-trust steps as a suffix appended to a dockerfile service's OWN
845
+ * Dockerfile, so one build produces the finished image instead of a build
846
+ * plus a derivative rebuild ({@link ensureCaTrustedImage}) per service.
847
+ * Returns null when there is no CA to layer, or when the PEM can't be
848
+ * quoted — the caller then falls back to the derivative build.
849
+ *
850
+ * The PEM is written INLINE rather than `COPY`d: the build context is
851
+ * `/workspace` under a per-service ignore file that the project itself
852
+ * contributes to (a `**` line with re-includes is the common idiom), and
853
+ * a context path we don't control is a context path that can be excluded.
854
+ * printf needs nothing but a shell.
855
+ *
856
+ * Two rules make this safe to bolt onto user code. It must never fail the
857
+ * build — every branch ends in an echo, so the RUN exits 0 whatever the
858
+ * image lacks — and it must never change the image, beyond the trust
859
+ * store: notably no `USER root`, since we cannot know statically what
860
+ * user to hand back. An image that declares a non-root user therefore
861
+ * fails to write and is finished by the derivative build, which inspects
862
+ * the built image and can escalate properly.
863
+ */
864
+ async function caTrustSuffix() {
865
+ if (!existsSync(CA_PATH))
866
+ return null;
867
+ const pem = (await fs.readFile(CA_PATH, "utf8")).trim();
868
+ // A quote in the PEM would break out of the shell quoting below. PEM is
869
+ // base64 and dashes, so this is a guard, not a case we expect.
870
+ if (!pem || pem.includes("'"))
871
+ return null;
872
+ const args = pem
873
+ .split("\n")
874
+ .map((l) => `'${l.trimEnd()}'`)
875
+ .join(" ");
876
+ const dst = "/usr/local/share/ca-certificates/spectest-ca.crt";
877
+ return `
878
+ # spectest: trust the environment's root CA. Appended by the harness —
879
+ # not part of the project's Dockerfile.
880
+ RUN P='[spectest-ca]'; \\
881
+ mkdir -p /usr/local/share/ca-certificates 2>/dev/null; \\
882
+ if printf '%s\\n' ${args} > ${dst} 2>/dev/null; then \\
883
+ if command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1; then \\
884
+ echo "$P trusted via update-ca-certificates"; \\
885
+ elif command -v update-ca-trust >/dev/null 2>&1 && cp ${dst} /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract >/dev/null 2>&1; then \\
886
+ echo "$P trusted via update-ca-trust"; \\
887
+ else \\
888
+ echo "$P no system CA trust tool in image; env-var trust only"; \\
889
+ fi; \\
890
+ else \\
891
+ echo "$P trust store not writable by this image's user"; \\
892
+ fi
893
+ `;
894
+ }
895
+ /**
896
+ * Build a dockerfile service's image.
897
+ *
898
+ * The CA-trust layer is folded into THIS build when it can be (see
899
+ * {@link caTrustSuffix}), so a service costs one image build and one
900
+ * export rather than two. The derivative build stays as the fallback for
901
+ * everything the folded form can't serve: an image with no shell to run
902
+ * the step (distroless, scratch — the appended RUN can't execute, so the
903
+ * build fails and we rebuild the project's Dockerfile untouched), and an
904
+ * image that declares a non-root user (the step runs as that user and
905
+ * can't write the trust store).
906
+ */
832
907
  async function buildServiceImage(name, image, tag) {
908
+ const suffix = await caTrustSuffix();
909
+ let attempt = await runServiceBuild(name, image, tag, suffix);
910
+ if (!attempt.ok && suffix) {
911
+ // Our step must not be able to break a project's build.
912
+ // eslint-disable-next-line no-console
913
+ console.warn(`[ca-trust] folded CA step could not run in ${name}'s image; rebuilding without it`);
914
+ attempt = await runServiceBuild(name, image, tag, null);
915
+ }
916
+ if (!attempt.ok) {
917
+ progressService(name, { status: "failed" });
918
+ throw new Error(`docker build for ${name} failed:\n${attempt.log}`);
919
+ }
920
+ // The derivative build is still needed for the one thing the folded step
921
+ // cannot do: write the trust store of an image that does not run as
922
+ // root. That is decided on the IMAGE, not on the build log — a CACHED
923
+ // layer prints nothing, so a log-only check would quietly stop
924
+ // re-applying the moment BuildKit had the layer. The log covers the
925
+ // rarer case of a root image whose /etc is read-only.
926
+ //
927
+ // An image with no trust tool at all needs nothing further: the
928
+ // derivative build would reach the same dead end, and `runContainer`'s
929
+ // env vars are the fallback either way.
930
+ const needsDerivative = !attempt.folded ||
931
+ attempt.log.includes(CA_FOLD_UNWRITABLE) ||
932
+ !(await imageRunsAsRoot(tag));
933
+ if (needsDerivative)
934
+ await ensureCaTrustedImage(name, tag);
935
+ return { tag, buildSteps: attempt.buildSteps };
936
+ }
937
+ /** Whether `tag`'s declared `USER` is root (or unset, which means root). */
938
+ async function imageRunsAsRoot(tag) {
939
+ const user = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
940
+ return user === "" || user === "root" || user === "0";
941
+ }
942
+ async function runServiceBuild(name, image, tag, caSuffix) {
833
943
  let buildSteps;
834
944
  {
945
+ const content = caSuffix
946
+ ? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
947
+ : image.content;
835
948
  const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
836
949
  await fs.mkdir(dfDir, { recursive: true });
837
950
  const dfPath = path.join(dfDir, "Dockerfile");
838
- await fs.writeFile(dfPath, image.content);
951
+ await fs.writeFile(dfPath, content);
839
952
  // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
840
953
  // (next to the Dockerfile) in preference to the context root's
841
954
  // `.dockerignore`, so this build sees the defaults, the project's own
@@ -897,9 +1010,11 @@ async function buildServiceImage(name, image, tag) {
897
1010
  });
898
1011
  }
899
1012
  });
1013
+ const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
900
1014
  if (build.code !== 0) {
901
- progressService(name, { status: "failed" });
902
- throw new Error(`docker build for ${name} failed:\n${build.stderr.trim()}\n${build.stdout.trim()}`);
1015
+ // The caller decides whether this is fatal: a failure with the CA
1016
+ // step appended is retried without it before anyone hears about it.
1017
+ return { ok: false, folded: caSuffix !== null, log };
903
1018
  }
904
1019
  if (useBuildKit) {
905
1020
  // Keep only the slowest dozen steps ≥1s — enough to profile, small
@@ -908,14 +1023,8 @@ async function buildServiceImage(name, image, tag) {
908
1023
  .filter((s) => s.secs >= 1)
909
1024
  .slice(0, 12);
910
1025
  }
1026
+ return { ok: true, folded: caSuffix !== null, log, buildSteps };
911
1027
  }
912
- // Layer the spectest CA into the image's system trust store so apps
913
- // that read the system bundle (Go, Java, CLIs that don't honour the
914
- // SSL_CERT_FILE env vars) accept HTTPS to fakes. Best-effort: images
915
- // without `update-ca-certificates` / `update-ca-trust` (distroless,
916
- // scratch) fall through to the env-var path that `runContainer` sets.
917
- await ensureCaTrustedImage(name, tag);
918
- return { tag, buildSteps };
919
1028
  }
920
1029
  /**
921
1030
  * Build a derivative image on top of `tag` that copies the spectest
@@ -2294,20 +2403,39 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2294
2403
  error: errMessage(err),
2295
2404
  }, resv);
2296
2405
  };
2406
+ // A helper call is ONE step. Whatever the fake does inside it — a fetch to
2407
+ // deliver a webhook, a call to its own API — is the fake's plumbing, not
2408
+ // something the test did, and it would otherwise land on the timeline as
2409
+ // an `http` step the test never made. The built-in `email()` helpers
2410
+ // already pause around their internal polls by hand for exactly this
2411
+ // reason (`components/email.ts`); doing it here gives every user-authored
2412
+ // fake the same contract without having to know about it.
2413
+ //
2414
+ // The pause spans the helper's `await`s, so genuinely concurrent test work
2415
+ // (`Promise.all([helper(), ctx.fetch(…)])`) loses its events too. That is
2416
+ // the same trade `email()` has always made, and sequential test code — all
2417
+ // of it, in practice — is unaffected.
2418
+ pauseRecording();
2297
2419
  let result;
2298
2420
  try {
2299
2421
  result = fn.apply(thisArg, args);
2300
2422
  }
2301
2423
  catch (err) {
2424
+ resumeRecording();
2302
2425
  recordError(err);
2303
2426
  throw err;
2304
2427
  }
2305
2428
  if (result instanceof Promise) {
2306
- return result.then(recordResult, (err) => {
2429
+ return result.then((value) => {
2430
+ resumeRecording();
2431
+ return recordResult(value);
2432
+ }, (err) => {
2433
+ resumeRecording();
2307
2434
  recordError(err);
2308
2435
  throw err;
2309
2436
  });
2310
2437
  }
2438
+ resumeRecording();
2311
2439
  return recordResult(result);
2312
2440
  }
2313
2441
  /** Record a fake-helper call's render annotation as a child of the call's
@@ -2337,6 +2465,19 @@ function recordAnnotationChild(fakeName, member, annotation, durationMs, parentS
2337
2465
  durationMs,
2338
2466
  });
2339
2467
  break;
2468
+ case "chat":
2469
+ // A chat has no event kind of its own: it describes itself in
2470
+ // presentation terms (title + blocks), which is the seam a new step
2471
+ // type is supposed to use. `kind` stays an opaque grouping label.
2472
+ recordStep({
2473
+ kind: "chat",
2474
+ parentSeq,
2475
+ annotation: true,
2476
+ title: annotation.title ?? `${fakeName}.${member}`,
2477
+ blocks: annotation.blocks,
2478
+ durationMs,
2479
+ });
2480
+ break;
2340
2481
  }
2341
2482
  }
2342
2483
  function errMessage(err) {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Pr
3
3
  import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
4
4
  export { field } from "./inspect.js";
5
5
  export { annotate } from "./annotate.js";
6
- export type { Annotated, AnnotationKind, AnnotationOptions, EmailAnnotation, } from "./annotate.js";
6
+ export type { Annotated, AnnotationKind, AnnotationOptions, ChatAnnotation, ChatMessageAnnotation, EmailAnnotation, } from "./annotate.js";
7
7
  import type { Annotated } from "./annotate.js";
8
8
  export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
9
9
  export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
package/dist/index.js CHANGED
@@ -17,11 +17,12 @@ import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
17
17
  // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
18
18
  export { field } from "./inspect.js";
19
19
  // Render annotations for fake helpers: a helper returns
20
- // `annotate(value, "email", { … })` when its value is an email, and the
21
- // fake step it records gains a nested `email` event — the same one the
22
- // built-in `email()` component records — so the step's panel can draw the
23
- // mail, tabbing to the raw JSON value. The kind picks the options' type, and the test still gets
24
- // the raw value, with the raw value's type — see `annotate.ts`.
20
+ // `annotate(value, "email", { … })` or `annotate(value, "chat", { … })`
21
+ // when its value is one of those, and the fake step it records gains a
22
+ // nested event drawing it — an email mail-client style, a conversation as
23
+ // bubbles — which the step's panel leads with, tabbing to the raw JSON
24
+ // value. The kind picks the options' type, and the test still gets the raw
25
+ // value, with the raw value's type — see `annotate.ts`.
25
26
  export { annotate } from "./annotate.js";
26
27
  // Instrumented client primitives — drop-in replacements for Bun's native
27
28
  // clients that record each operation on the test event log and return their
@@ -523,6 +524,15 @@ function buildLocatorMatchers(loc, negated, message) {
523
524
  const started = Date.now();
524
525
  const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
525
526
  let actual;
527
+ // Whether this matcher's subject is meant to be on screen, i.e. whether
528
+ // the step should carry a reveal target for the replay (see
529
+ // `LocatorProbe.settle`). Only the visibility matchers flip with `negated`
530
+ // — `not.toHaveText(...)` is still an element the reader should be looking
531
+ // at — and `toHaveCount(0)` is the third way of saying "expect nothing".
532
+ const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
533
+ const expectsGone = (visibility && (matcher === "toBeHidden") !== negated) ||
534
+ (matcher === "toHaveCount" && expected === 0);
535
+ const settleOpts = { reveal: !expectsGone };
526
536
  for (;;) {
527
537
  let satisfied;
528
538
  try {
@@ -540,7 +550,7 @@ function buildLocatorMatchers(loc, negated, message) {
540
550
  }
541
551
  const passed = satisfied !== negated;
542
552
  if (passed) {
543
- const sourceSeq = await probe.settle(matcher, Date.now() - started);
553
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, undefined, settleOpts);
544
554
  recordAssertion({
545
555
  matcher,
546
556
  negated,
@@ -554,7 +564,7 @@ function buildLocatorMatchers(loc, negated, message) {
554
564
  }
555
565
  if (Date.now() >= deadline) {
556
566
  const msg = describe(actual);
557
- const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
567
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
558
568
  recordAssertion({
559
569
  matcher,
560
570
  negated,
package/dist/locator.d.ts CHANGED
@@ -151,8 +151,16 @@ export interface LocatorProbe {
151
151
  * locator's `expect(...)` matcher assertion nests under, and return its seq
152
152
  * (provenance + replay seek). `action` is the matcher name, `waitedMs` the
153
153
  * poll time, `error` marks the step failed on a timed-out matcher. Returns
154
- * `undefined` when nothing is recording. */
155
- settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
154
+ * `undefined` when nothing is recording.
155
+ *
156
+ * `opts.reveal` additionally stamps the element's rrweb node id for the
157
+ * replay viewer (see {@link stampRevealTarget}). The caller decides,
158
+ * because only it knows whether the matcher expects the element on screen:
159
+ * probing for one a passing `toBeHidden()` says is gone would do nothing
160
+ * but wait out the probe's own timeout. */
161
+ settle(action: string, waitedMs: number, error?: string, opts?: {
162
+ reveal?: boolean;
163
+ }): Promise<number | undefined>;
156
164
  }
157
165
  /** Silent (non-recorded) reads an `expect(browser)` matcher polls — the
158
166
  * session twin of {@link LocatorProbe}. Lives here, next to it, so `index.ts`
package/dist/locator.js CHANGED
@@ -244,9 +244,22 @@ async function stampActionPoint(loc, rec, position) {
244
244
  const pt = await loc.evaluate((el, pos) => {
245
245
  if (window.top !== window)
246
246
  return null;
247
+ // rrweb's id for this element, in the same one round trip. It is what
248
+ // the replay actually needs: a coordinate is measured against the VM's
249
+ // layout a moment BEFORE playwright's own actionability wait, so an
250
+ // element still animating in (or one the dashboard's fonts lay out a
251
+ // little differently) leaves the recorded point beside the element
252
+ // rather than on it — measured 27px low on a modal with an entrance
253
+ // animation. The id lets the viewer place the cursor from the replay's
254
+ // own layout instead. Stamped even when the point below is not, since
255
+ // the viewer can scroll an off-screen element into view.
256
+ const w = window;
257
+ const getId = w.__spectestRec?.mirror?.getId;
258
+ const rawId = typeof getId === "function" ? getId.call(w.__spectestRec.mirror, el) : 0;
259
+ const nodeId = typeof rawId === "number" && rawId > 0 ? rawId : 0;
247
260
  const r = el.getBoundingClientRect();
248
261
  if (!r.width || !r.height)
249
- return null;
262
+ return nodeId ? { nodeId } : null;
250
263
  // Playwright clicks the element's centre unless the caller named a
251
264
  // point — which it takes relative to the PADDING box, so an element
252
265
  // with a border (a plain `<button>` has 2px of it) sits that far off
@@ -255,17 +268,57 @@ async function stampActionPoint(loc, rec, position) {
255
268
  const x = cs ? r.left + parseFloat(cs.borderLeftWidth) + pos.x : r.left + r.width / 2;
256
269
  const y = cs ? r.top + parseFloat(cs.borderTopWidth) + pos.y : r.top + r.height / 2;
257
270
  const onScreen = x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
258
- return onScreen ? { x, y } : null;
271
+ return onScreen ? { x, y, nodeId } : { nodeId };
259
272
  }, position, { timeout: POINT_PROBE_MS });
260
- if (pt) {
273
+ if (pt && pt.x !== undefined) {
261
274
  rec.x = Math.round(pt.x);
262
275
  rec.y = Math.round(pt.y);
263
276
  }
277
+ if (pt && pt.nodeId)
278
+ rec.targetNodeId = pt.nodeId;
264
279
  }
265
280
  catch {
266
281
  /* Element not ready / gone / strict violation — the action reports it. */
267
282
  }
268
283
  }
284
+ /** Stamp the rrweb node id of the element a settled `expect(locator)` step is
285
+ * about, so the dashboard can bring it into the replay's view.
286
+ *
287
+ * Playwright's idea of "visible" is a non-empty box that isn't hidden — it
288
+ * says nothing about the viewport, so an element below the fold passes
289
+ * `toBeVisible()`. The replay then shows the recorded viewport, which is a
290
+ * frame the asserted element isn't in: the reader sees a page that looks
291
+ * unrelated to the step. The id is the element's identity in the recording
292
+ * (rrweb's own mirror, the same id space its mutation events carry), so the
293
+ * viewer can find the node in the replayed DOM and scroll it into view —
294
+ * measured against the replay's real layout rather than a rect we recorded
295
+ * here, and correct for an element inside a scrollable container too.
296
+ *
297
+ * Best-effort like {@link stampActionPoint}, and only for the top-level
298
+ * document: a frame's recorder has its own mirror, whose ids mean nothing in
299
+ * the main frame's stream. An unserialized node (`getId` → -1) or no recorder
300
+ * leaves the step unstamped, which just means the viewer doesn't scroll. */
301
+ async function stampRevealTarget(loc, rec) {
302
+ try {
303
+ const id = await loc.evaluate((el) => {
304
+ if (window.top !== window)
305
+ return 0;
306
+ // The bootstrap stashes rrweb's `record` here; `mirror` is its
307
+ // node ↔ id map, shared by every snapshot it takes (see browser.ts).
308
+ const w = window;
309
+ const getId = w.__spectestRec?.mirror?.getId;
310
+ if (typeof getId !== "function")
311
+ return 0;
312
+ const id = getId.call(w.__spectestRec.mirror, el);
313
+ return typeof id === "number" && id > 0 ? id : 0;
314
+ }, undefined, { timeout: POINT_PROBE_MS });
315
+ if (id)
316
+ rec.targetNodeId = id;
317
+ }
318
+ catch {
319
+ /* Element gone / not attached / strict violation — no reveal target. */
320
+ }
321
+ }
269
322
  /** Fold a {@link InputFiles} argument into the one playwright takes: repo
270
323
  * paths resolved to their in-VM location (see project-files.ts), built files
271
324
  * given a default mime type and a real `Buffer`. The names come back too —
@@ -320,7 +373,13 @@ export function makeLocator(backend, strategy, chain) {
320
373
  count: () => backend.silentRead((page) => lower(page, chain).count()),
321
374
  isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
322
375
  isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
323
- settle: (action, waitedMs, error) => backend.recordSettled(action, { selector: label }, waitedMs, error),
376
+ settle: async (action, waitedMs, error, opts) => {
377
+ const fields = { selector: label };
378
+ if (opts && opts.reveal) {
379
+ await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
380
+ }
381
+ return backend.recordSettled(action, fields, waitedMs, error);
382
+ },
324
383
  };
325
384
  const loc = {
326
385
  [LOCATOR_BRAND]: true,
@@ -1,4 +1,4 @@
1
- export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent;
1
+ export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
2
2
  interface BaseEvent {
3
3
  /** Order of *start* within the test. Reserved when an op begins (see
4
4
  * `reserveEvent`), so an op whose nested children finish — and record —
@@ -418,6 +418,15 @@ export interface BrowserEvent extends BaseEvent {
418
418
  dy?: number;
419
419
  x?: number;
420
420
  y?: number;
421
+ /**
422
+ * rrweb node id of the element a settled `expect(locator)` step asserted on
423
+ * — the element's identity in this session's recording. Playwright calls an
424
+ * element below the fold visible, so the frame the step seeks to need not
425
+ * contain it; with this the viewer finds the node in the replayed DOM and
426
+ * scrolls it into view. Absent when the SDK couldn't measure it (an element
427
+ * in a frame, one rrweb hasn't serialized, a matcher whose subject is gone).
428
+ */
429
+ targetNodeId?: number;
421
430
  /** Screenshot image format. */
422
431
  format?: string;
423
432
  /** For `waitFor`: how many times the predicate was polled. */
@@ -557,6 +566,74 @@ export declare function recordTerminal(ev: Omit<TerminalEvent, "seq" | "tOffsetM
557
566
  export declare function recordTerminalStep(ev: Omit<TerminalStepEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
558
567
  export declare function recordFake(ev: Omit<FakeEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
559
568
  export declare function recordEnv(ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
569
+ /**
570
+ * One block of step detail, in the dashboard's presentation vocabulary
571
+ * (`crates/control-plane/src/web/blocks.rs`, which owns the closed set).
572
+ * A server that predates a block type skips it rather than failing, so a
573
+ * newer SDK's step degrades to the blocks the server knows.
574
+ */
575
+ export type StepBlock = {
576
+ type: "text";
577
+ text: string;
578
+ } | {
579
+ type: "code";
580
+ code: string;
581
+ lang?: string;
582
+ label?: string;
583
+ } | {
584
+ type: "json";
585
+ value: unknown;
586
+ label?: string;
587
+ } | {
588
+ type: "kv";
589
+ rows: {
590
+ label: string;
591
+ value?: string;
592
+ error?: boolean;
593
+ }[];
594
+ } | {
595
+ type: "table";
596
+ columns: string[];
597
+ rows: unknown[][];
598
+ } | {
599
+ type: "htmlSandbox";
600
+ html: string;
601
+ label?: string;
602
+ } | {
603
+ type: "chat";
604
+ messages: ChatBlockMessage[];
605
+ label?: string;
606
+ };
607
+ /** One message of a `chat` block. */
608
+ export interface ChatBlockMessage {
609
+ /** Who sent it. The transcript is drawn as the end user would see it, so
610
+ * `self` (the end user's own messages) sits on the right and `other` on
611
+ * the left. */
612
+ side?: "self" | "other";
613
+ text?: string;
614
+ /** Arrived in *this* step: drawn with an entrance animation. Only the
615
+ * producer knows this, so only the producer sets it. */
616
+ new?: boolean;
617
+ }
618
+ /**
619
+ * A step that describes itself in presentation terms — a title plus an
620
+ * ordered list of blocks — instead of as one of the recorder's hard-coded
621
+ * kinds. This is the seam that lets a component ship a new kind of step
622
+ * with no server change: `kind` stays an opaque grouping/diff-alignment
623
+ * label and never decides how the step draws.
624
+ */
625
+ export interface StepEvent extends BaseEvent {
626
+ /** Opaque. Groups the step and aligns it across runs; never matched on
627
+ * to pick a renderer. */
628
+ kind: string;
629
+ /** The step's one-line summary in the timeline. */
630
+ title: string;
631
+ blocks?: StepBlock[];
632
+ status?: "passed" | "failed" | "error";
633
+ durationMs?: number;
634
+ error?: string;
635
+ }
636
+ export declare function recordStep(ev: Omit<StepEvent, "seq" | "tOffsetMs">, reservation?: EventReservation): number | undefined;
560
637
  export declare function recordEmail(ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
561
638
  /**
562
639
  * Recorded once per `ctx.poll(...)` call. Stands in for the suppressed