@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/recorder.js CHANGED
@@ -49,14 +49,24 @@ class Recorder {
49
49
  this.events.length = toLen;
50
50
  }
51
51
  }
52
- /** Stamp `parentSeq` onto every event at index >= `startIdx`. Used
53
- * by `ctx.poll` to group the kept iteration's events under the
54
- * resulting wait event. */
52
+ /** Stamp `parentSeq` onto every event at index >= `startIdx` that isn't
53
+ * already grouped under something else. Used by `ctx.poll` to group the
54
+ * kept iteration's events under the resulting wait event.
55
+ *
56
+ * An event that already carries a `parentSeq` keeps it: it belongs to a
57
+ * step *inside* this one, and re-parenting it here would flatten the
58
+ * nesting the UI renders from. The case that made this matter is a fake
59
+ * helper called in a poll predicate — its `annotate(...)` child is the
60
+ * fake call's value, and stamping the wait's seq over it made the WAIT
61
+ * render as an email/chat while the fake call showed raw JSON. Its
62
+ * ancestor is still the wait, one level up through the fake event. */
55
63
  markChildren(startIdx, parentSeq) {
56
64
  for (let i = startIdx; i < this.events.length; i++) {
57
65
  const ev = this.events[i];
58
66
  if (ev.seq === parentSeq)
59
67
  continue;
68
+ if (ev.parentSeq !== undefined)
69
+ continue;
60
70
  ev.parentSeq = parentSeq;
61
71
  }
62
72
  }
@@ -178,6 +188,9 @@ export function recordFake(ev, reservation) {
178
188
  export function recordEnv(ev, reservation) {
179
189
  return active() ? current.push({ kind: "env", ...ev }, reservation) : undefined;
180
190
  }
191
+ export function recordStep(ev, reservation) {
192
+ return active() ? current.push(ev, reservation) : undefined;
193
+ }
181
194
  export function recordEmail(ev, reservation) {
182
195
  return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
183
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/annotate.ts CHANGED
@@ -33,8 +33,10 @@
33
33
  import { deepUnwrap } from "./inspect.js";
34
34
  import {
35
35
  truncateUtf8,
36
+ type ChatBlockMessage,
36
37
  type EmailEventMessage,
37
38
  type EmailEventSummary,
39
+ type StepBlock,
38
40
  } from "./recorder.js";
39
41
 
40
42
  /**
@@ -74,6 +76,9 @@ export interface AnnotationOptions {
74
76
  /** Draw the value as an email — one message, or a list of them for a
75
77
  * mailbox listing. See {@link EmailAnnotation}. */
76
78
  email: EmailAnnotation | readonly EmailAnnotation[];
79
+ /** Draw the value as a chat transcript — bubbles by role, with the
80
+ * messages that arrived in this call marked. See {@link ChatAnnotation}. */
81
+ chat: ChatAnnotation | readonly ChatMessageAnnotation[];
77
82
  }
78
83
 
79
84
  /** The kinds {@link annotate} accepts. */
@@ -85,7 +90,9 @@ export type AnnotationKind = keyof AnnotationOptions;
85
90
  * which needn't be the annotation kind's name, since an annotation names
86
91
  * what the value *is* and the event names how it's drawn.
87
92
  */
88
- export type RenderAnnotation = {
93
+ export type RenderAnnotation = EmailRenderAnnotation | ChatRenderAnnotation;
94
+
95
+ type EmailRenderAnnotation = {
89
96
  kind: "email";
90
97
  /** Single-message ops. */
91
98
  message?: EmailEventMessage;
@@ -95,6 +102,15 @@ export type RenderAnnotation = {
95
102
  count?: number;
96
103
  };
97
104
 
105
+ /** A chat lowers to a step that describes itself in presentation terms — a
106
+ * title plus blocks — rather than to a kind of its own: `blocks.rs` owns
107
+ * the drawing vocabulary, and a transcript is one of its block types. */
108
+ type ChatRenderAnnotation = {
109
+ kind: "chat";
110
+ title?: string;
111
+ blocks: StepBlock[];
112
+ };
113
+
98
114
  /**
99
115
  * The email fields to render. Every one is optional — pass what the fake
100
116
  * has. Addresses take a single string or a list.
@@ -117,6 +133,46 @@ export interface EmailAnnotation {
117
133
  text?: string;
118
134
  }
119
135
 
136
+ /**
137
+ * A conversation to draw as chat bubbles.
138
+ *
139
+ * Two sides and their text, which is all a transcript needs to read as one.
140
+ * It is drawn from the end user's point of view — their own messages on the
141
+ * right, the other party's on the left.
142
+ * Pass the messages alone (`annotate(v, "chat", messages)`), or this object
143
+ * when the thread has an identity worth showing. With no options at all the
144
+ * value itself is read as the message list.
145
+ */
146
+ export interface ChatAnnotation {
147
+ messages: readonly ChatMessageAnnotation[];
148
+ /**
149
+ * The thread's identity — a phone number, a channel, whoever is on the
150
+ * other end — shown as a header over the bubbles. Optional: a fake with
151
+ * one conversation has nothing useful to put here, and the transcript
152
+ * reads fine without it.
153
+ */
154
+ title?: string;
155
+ }
156
+
157
+ /** One turn of a {@link ChatAnnotation}. */
158
+ export interface ChatMessageAnnotation {
159
+ /**
160
+ * Who sent it. A transcript is drawn the way the **end user** would see
161
+ * it on their own screen, so `self` is the end user's own messages — the
162
+ * person using the app under test — and sits on the right; `other` is
163
+ * whoever they are talking to and sits on the left.
164
+ */
165
+ side: "self" | "other";
166
+ text: string;
167
+ /**
168
+ * This message arrived in *this* call: it slides in when the step opens,
169
+ * and again whenever it is reopened. Only the fake knows which turns are new —
170
+ * the dashboard sees one call, not the conversation's history — so it is
171
+ * the fake that marks them.
172
+ */
173
+ new?: boolean;
174
+ }
175
+
120
176
  /** Listing rows carried on the event. The returned value is never capped. */
121
177
  const LIST_CAP = 50;
122
178
 
@@ -173,6 +229,8 @@ function lower<K extends AnnotationKind>(
173
229
  switch (kind) {
174
230
  case "email":
175
231
  return lowerEmail(options as AnnotationOptions["email"] | undefined, value);
232
+ case "chat":
233
+ return lowerChat(options as AnnotationOptions["chat"] | undefined, value);
176
234
  default:
177
235
  // Unreachable while `kind` is a key of AnnotationOptions; a kind added
178
236
  // to that interface without a case here lands on this line.
@@ -230,6 +288,51 @@ function fields(v: unknown): Record<string, unknown> {
230
288
  : {};
231
289
  }
232
290
 
291
+ const NO_MESSAGES =
292
+ 'annotate(value, "chat"): nothing to render — no messages found on the ' +
293
+ "value. Pass them explicitly, e.g. " +
294
+ 'annotate(value, "chat", value.turns.map((t) => ({ side: t.fromCustomer ? "self" : "other", text: t.body }))).';
295
+
296
+ function lowerChat(
297
+ options: AnnotationOptions["chat"] | undefined,
298
+ value: unknown,
299
+ ): RenderAnnotation {
300
+ const source: unknown = deepUnwrap(options ?? value);
301
+ let list: unknown;
302
+ let title: string | undefined;
303
+ if (Array.isArray(source)) {
304
+ list = source;
305
+ } else {
306
+ const bag = fields(source);
307
+ list = bag.messages;
308
+ title = text(bag.title) || undefined;
309
+ }
310
+ // An empty conversation is a real state (nothing said yet) and renders as
311
+ // one; a value with no `messages` at all is the author's mistake.
312
+ if (!Array.isArray(list)) throw new Error(NO_MESSAGES);
313
+ const messages = list.map((m) => chatMessageOf(fields(m)));
314
+ // The title travels twice on purpose: as the step's own summary, and as
315
+ // the transcript's header — the annotated view renders the blocks alone,
316
+ // so a title carried only on the step would never be seen.
317
+ return {
318
+ kind: "chat",
319
+ title,
320
+ blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
321
+ };
322
+ }
323
+
324
+ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
325
+ const msg: ChatBlockMessage = {};
326
+ // Anything that isn't the end user's own message is drawn as the other
327
+ // party's, rather than a turn being dropped for a typo.
328
+ if (text(src.side) === "self") msg.side = "self";
329
+ else msg.side = "other";
330
+ const body = text(src.text);
331
+ if (body) msg.text = truncateUtf8(body).value;
332
+ if (src.new === true) msg.new = true;
333
+ return msg;
334
+ }
335
+
233
336
  function messageOf(src: Record<string, unknown>): EmailEventMessage {
234
337
  const msg: EmailEventMessage = {};
235
338
  const from = text(src.from);
package/src/browser.ts CHANGED
@@ -2093,4 +2093,5 @@ export interface RecordableFields {
2093
2093
  artifactId: string;
2094
2094
  attribute: string;
2095
2095
  files: string[];
2096
+ targetNodeId: number;
2096
2097
  }
package/src/daemon.ts CHANGED
@@ -114,17 +114,20 @@ import type { Mobile, MobileApp } from "./mobile.js";
114
114
  import { openTerminal } from "./terminal.js";
115
115
  import { readAnnotation, type RenderAnnotation } from "./annotate.js";
116
116
  import {
117
+ pauseRecording,
117
118
  recordEmail,
118
119
  recordEnv,
119
120
  recordExec,
120
121
  recordFake,
121
122
  recordHttp,
123
+ recordStep,
122
124
  recordTerminal,
123
125
  recordWait,
124
126
  reserveEvent,
125
127
  recorderEventCount,
126
128
  recorderMarkChildren,
127
129
  recorderTruncate,
130
+ resumeRecording,
128
131
  startRecording,
129
132
  stopRecording,
130
133
  truncateUtf8,
@@ -1113,17 +1116,141 @@ async function prepareServiceImage(
1113
1116
  return buildServiceImage(svc.name, image, tag);
1114
1117
  }
1115
1118
 
1119
+ /**
1120
+ * What the folded CA step prints when it could not write the trust store
1121
+ * at all — the one outcome that still needs {@link ensureCaTrustedImage},
1122
+ * which can take root for the write.
1123
+ *
1124
+ * The step assembles this prefix from a shell variable so that the marker
1125
+ * appears ONLY in the step's output. BuildKit echoes each instruction into
1126
+ * the same log verbatim, so a marker written literally in the RUN would
1127
+ * match on every build whether or not the step ever printed it.
1128
+ */
1129
+ const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
1130
+
1131
+ /**
1132
+ * The CA-trust steps as a suffix appended to a dockerfile service's OWN
1133
+ * Dockerfile, so one build produces the finished image instead of a build
1134
+ * plus a derivative rebuild ({@link ensureCaTrustedImage}) per service.
1135
+ * Returns null when there is no CA to layer, or when the PEM can't be
1136
+ * quoted — the caller then falls back to the derivative build.
1137
+ *
1138
+ * The PEM is written INLINE rather than `COPY`d: the build context is
1139
+ * `/workspace` under a per-service ignore file that the project itself
1140
+ * contributes to (a `**` line with re-includes is the common idiom), and
1141
+ * a context path we don't control is a context path that can be excluded.
1142
+ * printf needs nothing but a shell.
1143
+ *
1144
+ * Two rules make this safe to bolt onto user code. It must never fail the
1145
+ * build — every branch ends in an echo, so the RUN exits 0 whatever the
1146
+ * image lacks — and it must never change the image, beyond the trust
1147
+ * store: notably no `USER root`, since we cannot know statically what
1148
+ * user to hand back. An image that declares a non-root user therefore
1149
+ * fails to write and is finished by the derivative build, which inspects
1150
+ * the built image and can escalate properly.
1151
+ */
1152
+ async function caTrustSuffix(): Promise<string | null> {
1153
+ if (!existsSync(CA_PATH)) return null;
1154
+ const pem = (await fs.readFile(CA_PATH, "utf8")).trim();
1155
+ // A quote in the PEM would break out of the shell quoting below. PEM is
1156
+ // base64 and dashes, so this is a guard, not a case we expect.
1157
+ if (!pem || pem.includes("'")) return null;
1158
+ const args = pem
1159
+ .split("\n")
1160
+ .map((l) => `'${l.trimEnd()}'`)
1161
+ .join(" ");
1162
+ const dst = "/usr/local/share/ca-certificates/spectest-ca.crt";
1163
+ return `
1164
+ # spectest: trust the environment's root CA. Appended by the harness —
1165
+ # not part of the project's Dockerfile.
1166
+ RUN P='[spectest-ca]'; \\
1167
+ mkdir -p /usr/local/share/ca-certificates 2>/dev/null; \\
1168
+ if printf '%s\\n' ${args} > ${dst} 2>/dev/null; then \\
1169
+ if command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1; then \\
1170
+ echo "$P trusted via update-ca-certificates"; \\
1171
+ 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 \\
1172
+ echo "$P trusted via update-ca-trust"; \\
1173
+ else \\
1174
+ echo "$P no system CA trust tool in image; env-var trust only"; \\
1175
+ fi; \\
1176
+ else \\
1177
+ echo "$P trust store not writable by this image's user"; \\
1178
+ fi
1179
+ `;
1180
+ }
1181
+
1182
+ /**
1183
+ * Build a dockerfile service's image.
1184
+ *
1185
+ * The CA-trust layer is folded into THIS build when it can be (see
1186
+ * {@link caTrustSuffix}), so a service costs one image build and one
1187
+ * export rather than two. The derivative build stays as the fallback for
1188
+ * everything the folded form can't serve: an image with no shell to run
1189
+ * the step (distroless, scratch — the appended RUN can't execute, so the
1190
+ * build fails and we rebuild the project's Dockerfile untouched), and an
1191
+ * image that declares a non-root user (the step runs as that user and
1192
+ * can't write the trust store).
1193
+ */
1116
1194
  async function buildServiceImage(
1117
1195
  name: string,
1118
1196
  image: { content: string; exclude?: readonly string[] },
1119
1197
  tag: string,
1120
1198
  ): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
1199
+ const suffix = await caTrustSuffix();
1200
+ let attempt = await runServiceBuild(name, image, tag, suffix);
1201
+ if (!attempt.ok && suffix) {
1202
+ // Our step must not be able to break a project's build.
1203
+ // eslint-disable-next-line no-console
1204
+ console.warn(
1205
+ `[ca-trust] folded CA step could not run in ${name}'s image; rebuilding without it`,
1206
+ );
1207
+ attempt = await runServiceBuild(name, image, tag, null);
1208
+ }
1209
+ if (!attempt.ok) {
1210
+ progressService(name, { status: "failed" });
1211
+ throw new Error(`docker build for ${name} failed:\n${attempt.log}`);
1212
+ }
1213
+ // The derivative build is still needed for the one thing the folded step
1214
+ // cannot do: write the trust store of an image that does not run as
1215
+ // root. That is decided on the IMAGE, not on the build log — a CACHED
1216
+ // layer prints nothing, so a log-only check would quietly stop
1217
+ // re-applying the moment BuildKit had the layer. The log covers the
1218
+ // rarer case of a root image whose /etc is read-only.
1219
+ //
1220
+ // An image with no trust tool at all needs nothing further: the
1221
+ // derivative build would reach the same dead end, and `runContainer`'s
1222
+ // env vars are the fallback either way.
1223
+ const needsDerivative =
1224
+ !attempt.folded ||
1225
+ attempt.log.includes(CA_FOLD_UNWRITABLE) ||
1226
+ !(await imageRunsAsRoot(tag));
1227
+ if (needsDerivative) await ensureCaTrustedImage(name, tag);
1228
+ return { tag, buildSteps: attempt.buildSteps };
1229
+ }
1230
+
1231
+ /** Whether `tag`'s declared `USER` is root (or unset, which means root). */
1232
+ async function imageRunsAsRoot(tag: string): Promise<boolean> {
1233
+ const user = (
1234
+ await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)
1235
+ ).stdout.trim();
1236
+ return user === "" || user === "root" || user === "0";
1237
+ }
1238
+
1239
+ async function runServiceBuild(
1240
+ name: string,
1241
+ image: { content: string; exclude?: readonly string[] },
1242
+ tag: string,
1243
+ caSuffix: string | null,
1244
+ ): Promise<{ ok: boolean; folded: boolean; log: string; buildSteps?: BuildStep[] }> {
1121
1245
  let buildSteps: BuildStep[] | undefined;
1122
1246
  {
1247
+ const content = caSuffix
1248
+ ? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
1249
+ : image.content;
1123
1250
  const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
1124
1251
  await fs.mkdir(dfDir, { recursive: true });
1125
1252
  const dfPath = path.join(dfDir, "Dockerfile");
1126
- await fs.writeFile(dfPath, image.content);
1253
+ await fs.writeFile(dfPath, content);
1127
1254
  // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
1128
1255
  // (next to the Dockerfile) in preference to the context root's
1129
1256
  // `.dockerignore`, so this build sees the defaults, the project's own
@@ -1183,11 +1310,11 @@ async function buildServiceImage(
1183
1310
  });
1184
1311
  }
1185
1312
  });
1313
+ const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
1186
1314
  if (build.code !== 0) {
1187
- progressService(name, { status: "failed" });
1188
- throw new Error(
1189
- `docker build for ${name} failed:\n${build.stderr.trim()}\n${build.stdout.trim()}`,
1190
- );
1315
+ // The caller decides whether this is fatal: a failure with the CA
1316
+ // step appended is retried without it before anyone hears about it.
1317
+ return { ok: false, folded: caSuffix !== null, log };
1191
1318
  }
1192
1319
  if (useBuildKit) {
1193
1320
  // Keep only the slowest dozen steps ≥1s — enough to profile, small
@@ -1196,14 +1323,8 @@ async function buildServiceImage(
1196
1323
  .filter((s) => s.secs >= 1)
1197
1324
  .slice(0, 12);
1198
1325
  }
1326
+ return { ok: true, folded: caSuffix !== null, log, buildSteps };
1199
1327
  }
1200
- // Layer the spectest CA into the image's system trust store so apps
1201
- // that read the system bundle (Go, Java, CLIs that don't honour the
1202
- // SSL_CERT_FILE env vars) accept HTTPS to fakes. Best-effort: images
1203
- // without `update-ca-certificates` / `update-ca-trust` (distroless,
1204
- // scratch) fall through to the env-var path that `runContainer` sets.
1205
- await ensureCaTrustedImage(name, tag);
1206
- return { tag, buildSteps };
1207
1328
  }
1208
1329
 
1209
1330
  /**
@@ -2762,19 +2883,41 @@ function invokeFakeHelper(
2762
2883
  }, resv);
2763
2884
  };
2764
2885
 
2886
+ // A helper call is ONE step. Whatever the fake does inside it — a fetch to
2887
+ // deliver a webhook, a call to its own API — is the fake's plumbing, not
2888
+ // something the test did, and it would otherwise land on the timeline as
2889
+ // an `http` step the test never made. The built-in `email()` helpers
2890
+ // already pause around their internal polls by hand for exactly this
2891
+ // reason (`components/email.ts`); doing it here gives every user-authored
2892
+ // fake the same contract without having to know about it.
2893
+ //
2894
+ // The pause spans the helper's `await`s, so genuinely concurrent test work
2895
+ // (`Promise.all([helper(), ctx.fetch(…)])`) loses its events too. That is
2896
+ // the same trade `email()` has always made, and sequential test code — all
2897
+ // of it, in practice — is unaffected.
2898
+ pauseRecording();
2765
2899
  let result: unknown;
2766
2900
  try {
2767
2901
  result = fn.apply(thisArg, args);
2768
2902
  } catch (err) {
2903
+ resumeRecording();
2769
2904
  recordError(err);
2770
2905
  throw err;
2771
2906
  }
2772
2907
  if (result instanceof Promise) {
2773
- return result.then(recordResult, (err) => {
2774
- recordError(err);
2775
- throw err;
2776
- });
2908
+ return result.then(
2909
+ (value) => {
2910
+ resumeRecording();
2911
+ return recordResult(value);
2912
+ },
2913
+ (err) => {
2914
+ resumeRecording();
2915
+ recordError(err);
2916
+ throw err;
2917
+ },
2918
+ );
2777
2919
  }
2920
+ resumeRecording();
2778
2921
  return recordResult(result);
2779
2922
  }
2780
2923
 
@@ -2811,6 +2954,19 @@ function recordAnnotationChild(
2811
2954
  durationMs,
2812
2955
  });
2813
2956
  break;
2957
+ case "chat":
2958
+ // A chat has no event kind of its own: it describes itself in
2959
+ // presentation terms (title + blocks), which is the seam a new step
2960
+ // type is supposed to use. `kind` stays an opaque grouping label.
2961
+ recordStep({
2962
+ kind: "chat",
2963
+ parentSeq,
2964
+ annotation: true,
2965
+ title: annotation.title ?? `${fakeName}.${member}`,
2966
+ blocks: annotation.blocks,
2967
+ durationMs,
2968
+ });
2969
+ break;
2814
2970
  }
2815
2971
  }
2816
2972
 
package/src/index.ts CHANGED
@@ -35,16 +35,19 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
35
35
  export { field } from "./inspect.js";
36
36
 
37
37
  // Render annotations for fake helpers: a helper returns
38
- // `annotate(value, "email", { … })` when its value is an email, and the
39
- // fake step it records gains a nested `email` event — the same one the
40
- // built-in `email()` component records — so the step's panel can draw the
41
- // mail, tabbing to the raw JSON value. The kind picks the options' type, and the test still gets
42
- // the raw value, with the raw value's type — see `annotate.ts`.
38
+ // `annotate(value, "email", { … })` or `annotate(value, "chat", { … })`
39
+ // when its value is one of those, and the fake step it records gains a
40
+ // nested event drawing it — an email mail-client style, a conversation as
41
+ // bubbles — which the step's panel leads with, tabbing to the raw JSON
42
+ // value. The kind picks the options' type, and the test still gets the raw
43
+ // value, with the raw value's type — see `annotate.ts`.
43
44
  export { annotate } from "./annotate.js";
44
45
  export type {
45
46
  Annotated,
46
47
  AnnotationKind,
47
48
  AnnotationOptions,
49
+ ChatAnnotation,
50
+ ChatMessageAnnotation,
48
51
  EmailAnnotation,
49
52
  } from "./annotate.js";
50
53
  import type { Annotated } from "./annotate.js";
@@ -2325,6 +2328,16 @@ function buildLocatorMatchers(
2325
2328
  const started = Date.now();
2326
2329
  const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
2327
2330
  let actual: unknown;
2331
+ // Whether this matcher's subject is meant to be on screen, i.e. whether
2332
+ // the step should carry a reveal target for the replay (see
2333
+ // `LocatorProbe.settle`). Only the visibility matchers flip with `negated`
2334
+ // — `not.toHaveText(...)` is still an element the reader should be looking
2335
+ // at — and `toHaveCount(0)` is the third way of saying "expect nothing".
2336
+ const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
2337
+ const expectsGone =
2338
+ (visibility && (matcher === "toBeHidden") !== negated) ||
2339
+ (matcher === "toHaveCount" && expected === 0);
2340
+ const settleOpts = { reveal: !expectsGone };
2328
2341
  for (;;) {
2329
2342
  let satisfied: boolean;
2330
2343
  try {
@@ -2341,7 +2354,7 @@ function buildLocatorMatchers(
2341
2354
  }
2342
2355
  const passed = satisfied !== negated;
2343
2356
  if (passed) {
2344
- const sourceSeq = await probe.settle(matcher, Date.now() - started);
2357
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, undefined, settleOpts);
2345
2358
  recordAssertion({
2346
2359
  matcher,
2347
2360
  negated,
@@ -2355,7 +2368,7 @@ function buildLocatorMatchers(
2355
2368
  }
2356
2369
  if (Date.now() >= deadline) {
2357
2370
  const msg = describe(actual);
2358
- const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
2371
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
2359
2372
  recordAssertion({
2360
2373
  matcher,
2361
2374
  negated,
package/src/locator.ts CHANGED
@@ -322,8 +322,19 @@ export interface LocatorProbe {
322
322
  * locator's `expect(...)` matcher assertion nests under, and return its seq
323
323
  * (provenance + replay seek). `action` is the matcher name, `waitedMs` the
324
324
  * poll time, `error` marks the step failed on a timed-out matcher. Returns
325
- * `undefined` when nothing is recording. */
326
- settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
325
+ * `undefined` when nothing is recording.
326
+ *
327
+ * `opts.reveal` additionally stamps the element's rrweb node id for the
328
+ * replay viewer (see {@link stampRevealTarget}). The caller decides,
329
+ * because only it knows whether the matcher expects the element on screen:
330
+ * probing for one a passing `toBeHidden()` says is gone would do nothing
331
+ * but wait out the probe's own timeout. */
332
+ settle(
333
+ action: string,
334
+ waitedMs: number,
335
+ error?: string,
336
+ opts?: { reveal?: boolean },
337
+ ): Promise<number | undefined>;
327
338
  }
328
339
 
329
340
  /** Silent (non-recorded) reads an `expect(browser)` matcher polls — the
@@ -446,8 +457,23 @@ async function stampActionPoint(
446
457
  const pt = await loc.evaluate(
447
458
  (el, pos) => {
448
459
  if (window.top !== window) return null;
460
+ // rrweb's id for this element, in the same one round trip. It is what
461
+ // the replay actually needs: a coordinate is measured against the VM's
462
+ // layout a moment BEFORE playwright's own actionability wait, so an
463
+ // element still animating in (or one the dashboard's fonts lay out a
464
+ // little differently) leaves the recorded point beside the element
465
+ // rather than on it — measured 27px low on a modal with an entrance
466
+ // animation. The id lets the viewer place the cursor from the replay's
467
+ // own layout instead. Stamped even when the point below is not, since
468
+ // the viewer can scroll an off-screen element into view.
469
+ const w = window as unknown as {
470
+ __spectestRec?: { mirror?: { getId?: (n: Node) => number } };
471
+ };
472
+ const getId = w.__spectestRec?.mirror?.getId;
473
+ const rawId = typeof getId === "function" ? getId.call(w.__spectestRec!.mirror, el) : 0;
474
+ const nodeId = typeof rawId === "number" && rawId > 0 ? rawId : 0;
449
475
  const r = el.getBoundingClientRect();
450
- if (!r.width || !r.height) return null;
476
+ if (!r.width || !r.height) return nodeId ? { nodeId } : null;
451
477
  // Playwright clicks the element's centre unless the caller named a
452
478
  // point — which it takes relative to the PADDING box, so an element
453
479
  // with a border (a plain `<button>` has 2px of it) sits that far off
@@ -457,20 +483,65 @@ async function stampActionPoint(
457
483
  const y = cs ? r.top + parseFloat(cs.borderTopWidth) + pos!.y : r.top + r.height / 2;
458
484
  const onScreen =
459
485
  x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
460
- return onScreen ? { x, y } : null;
486
+ return onScreen ? { x, y, nodeId } : { nodeId };
461
487
  },
462
488
  position,
463
489
  { timeout: POINT_PROBE_MS },
464
490
  );
465
- if (pt) {
491
+ if (pt && pt.x !== undefined) {
466
492
  rec.x = Math.round(pt.x);
467
- rec.y = Math.round(pt.y);
493
+ rec.y = Math.round(pt.y!);
468
494
  }
495
+ if (pt && pt.nodeId) rec.targetNodeId = pt.nodeId;
469
496
  } catch {
470
497
  /* Element not ready / gone / strict violation — the action reports it. */
471
498
  }
472
499
  }
473
500
 
501
+ /** Stamp the rrweb node id of the element a settled `expect(locator)` step is
502
+ * about, so the dashboard can bring it into the replay's view.
503
+ *
504
+ * Playwright's idea of "visible" is a non-empty box that isn't hidden — it
505
+ * says nothing about the viewport, so an element below the fold passes
506
+ * `toBeVisible()`. The replay then shows the recorded viewport, which is a
507
+ * frame the asserted element isn't in: the reader sees a page that looks
508
+ * unrelated to the step. The id is the element's identity in the recording
509
+ * (rrweb's own mirror, the same id space its mutation events carry), so the
510
+ * viewer can find the node in the replayed DOM and scroll it into view —
511
+ * measured against the replay's real layout rather than a rect we recorded
512
+ * here, and correct for an element inside a scrollable container too.
513
+ *
514
+ * Best-effort like {@link stampActionPoint}, and only for the top-level
515
+ * document: a frame's recorder has its own mirror, whose ids mean nothing in
516
+ * the main frame's stream. An unserialized node (`getId` → -1) or no recorder
517
+ * leaves the step unstamped, which just means the viewer doesn't scroll. */
518
+ async function stampRevealTarget(
519
+ loc: PWLocator,
520
+ rec: Partial<RecordableFields>,
521
+ ): Promise<void> {
522
+ try {
523
+ const id = await loc.evaluate(
524
+ (el) => {
525
+ if (window.top !== window) return 0;
526
+ // The bootstrap stashes rrweb's `record` here; `mirror` is its
527
+ // node ↔ id map, shared by every snapshot it takes (see browser.ts).
528
+ const w = window as unknown as {
529
+ __spectestRec?: { mirror?: { getId?: (n: Node) => number } };
530
+ };
531
+ const getId = w.__spectestRec?.mirror?.getId;
532
+ if (typeof getId !== "function") return 0;
533
+ const id = getId.call(w.__spectestRec!.mirror, el);
534
+ return typeof id === "number" && id > 0 ? id : 0;
535
+ },
536
+ undefined,
537
+ { timeout: POINT_PROBE_MS },
538
+ );
539
+ if (id) rec.targetNodeId = id;
540
+ } catch {
541
+ /* Element gone / not attached / strict violation — no reveal target. */
542
+ }
543
+ }
544
+
474
545
  /** Fold a {@link InputFiles} argument into the one playwright takes: repo
475
546
  * paths resolved to their in-VM location (see project-files.ts), built files
476
547
  * given a default mime type and a real `Buffer`. The names come back too —
@@ -671,8 +742,13 @@ export function makeLocator(
671
742
  count: () => backend.silentRead((page) => lower(page, chain).count()),
672
743
  isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
673
744
  isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
674
- settle: (action, waitedMs, error) =>
675
- backend.recordSettled(action, { selector: label }, waitedMs, error),
745
+ settle: async (action, waitedMs, error, opts) => {
746
+ const fields: Partial<RecordableFields> = { selector: label };
747
+ if (opts && opts.reveal) {
748
+ await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
749
+ }
750
+ return backend.recordSettled(action, fields, waitedMs, error);
751
+ },
676
752
  };
677
753
 
678
754
  const loc: InternalLocator = {