@specific.dev/spectest 0.50.0 → 0.52.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/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
@@ -46,7 +46,7 @@ import { conflict, notFound, requireString, } from "./harness/methods.js";
46
46
  import { openTerminal } from "./terminal.js";
47
47
  import { readAnnotation } from "./annotate.js";
48
48
  import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
49
- import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
49
+ import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
50
50
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
51
51
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
52
52
  function namedServices(cfg) {
@@ -3074,17 +3074,41 @@ const TEST_DATA = new Map();
3074
3074
  * test timeout instead, and a project `setup` — which routinely waits on
3075
3075
  * rollouts — stays unbounded, so neither gets a default. */
3076
3076
  const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
3077
- /** Raw `docker exec` with optional piped stdin. Array command = exact
3078
- * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
3079
- * `exitCode`, never thrown. Unlike the test-context `exec` this records
3080
- * nothing — setup/helpers-factory time has no test timeline. */
3077
+ /** The running test case's recording `exec`, installed by `runOne`
3078
+ * for the length of the case and cleared again in its `finally`. It is
3079
+ * read at *call* time, which is the whole point: a component's context —
3080
+ * a `helpers` factory's, a service `setup` hook's — is built once during
3081
+ * bootstrap and cached, so the `exec` those hooks destructure can never
3082
+ * be the test's own. Without this, everything a service helper did was
3083
+ * invisible: `ctx.svc.<name>.<helper>()` shelling into a container
3084
+ * recorded no step, and a helper called inside a `ctx.poll` predicate
3085
+ * left the wait with nothing to render inside it (the poll keeps the
3086
+ * events of its last attempt — there were none). Undefined outside a
3087
+ * test, so bootstrap and project setup stay uninstrumented. */
3088
+ let RECORDING_EXEC;
3089
+ /** `docker exec` with optional piped stdin. Array command = exact argv
3090
+ * (no shell); string = `sh -lc`. Non-zero exit is reported via
3091
+ * `exitCode`, never thrown.
3092
+ *
3093
+ * Records a step when a test is running (through `RECORDING_EXEC`, which
3094
+ * also captures the asciicast), and nothing at bootstrap. Either way the
3095
+ * caller gets the PLAIN `ExecResult`, never the test context's
3096
+ * provenance-wrapped one: component code is ordinary code written against
3097
+ * the declared type, and a wrapper is an object — `res.exitCode !== 0`
3098
+ * would be true for every exit code, and a client branching on `typeof`
3099
+ * would take the wrong arm. Provenance has nothing to point at here
3100
+ * anyway, since the helper's return value is not what the step recorded. */
3081
3101
  function componentExec(service, command, opts, defaultTimeoutMs) {
3082
3102
  assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3083
3103
  const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs;
3084
- return execInService(service, command, {
3104
+ const merged = {
3085
3105
  ...opts,
3086
3106
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3087
- });
3107
+ };
3108
+ const recording = RECORDING_EXEC;
3109
+ if (recording)
3110
+ return recording(service, command, merged).then(readRaw);
3111
+ return execInService(service, command, merged);
3088
3112
  }
3089
3113
  /** Transitive `dependsOn` closure of a service, by name. */
3090
3114
  function transitiveDeps(service) {
@@ -3918,6 +3942,12 @@ async function runOne(testCase) {
3918
3942
  testName: testCase.name,
3919
3943
  parent,
3920
3944
  };
3945
+ // Let a component's `exec` record for the length of this case. The
3946
+ // context a `helpers` factory or a service `setup` hook holds was built
3947
+ // at bootstrap and carries `componentExec`, which reads this at call
3948
+ // time — so from here on a service helper's `docker exec` lands on the
3949
+ // timeline (and in the cast) exactly like the test's own `ctx.exec`.
3950
+ RECORDING_EXEC = recordedExec;
3921
3951
  const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
3922
3952
  let timer;
3923
3953
  const timedOut = new Promise((_, reject) => {
@@ -3946,6 +3976,7 @@ async function runOne(testCase) {
3946
3976
  finally {
3947
3977
  if (timer)
3948
3978
  clearTimeout(timer);
3979
+ RECORDING_EXEC = undefined;
3949
3980
  restoreFetch();
3950
3981
  restoreConsole();
3951
3982
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
package/dist/index.js CHANGED
@@ -524,6 +524,15 @@ function buildLocatorMatchers(loc, negated, message) {
524
524
  const started = Date.now();
525
525
  const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
526
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 };
527
536
  for (;;) {
528
537
  let satisfied;
529
538
  try {
@@ -541,7 +550,7 @@ function buildLocatorMatchers(loc, negated, message) {
541
550
  }
542
551
  const passed = satisfied !== negated;
543
552
  if (passed) {
544
- const sourceSeq = await probe.settle(matcher, Date.now() - started);
553
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, undefined, settleOpts);
545
554
  recordAssertion({
546
555
  matcher,
547
556
  negated,
@@ -555,7 +564,7 @@ function buildLocatorMatchers(loc, negated, message) {
555
564
  }
556
565
  if (Date.now() >= deadline) {
557
566
  const msg = describe(actual);
558
- const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
567
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
559
568
  recordAssertion({
560
569
  matcher,
561
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,
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
@@ -134,7 +134,7 @@ import {
134
134
  type TestEvent,
135
135
  type OmittedBody,
136
136
  } from "./recorder.js";
137
- import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
137
+ import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
138
138
  import type { Wrapped, WrappedResponse } from "./inspect.js";
139
139
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
140
140
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
@@ -3773,10 +3773,37 @@ const TEST_DATA = new Map<string, unknown>();
3773
3773
  * rollouts — stays unbounded, so neither gets a default. */
3774
3774
  const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
3775
3775
 
3776
- /** Raw `docker exec` with optional piped stdin. Array command = exact
3777
- * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
3778
- * `exitCode`, never thrown. Unlike the test-context `exec` this records
3779
- * nothing — setup/helpers-factory time has no test timeline. */
3776
+ /** The running test case's recording `exec`, installed by `runOne`
3777
+ * for the length of the case and cleared again in its `finally`. It is
3778
+ * read at *call* time, which is the whole point: a component's context —
3779
+ * a `helpers` factory's, a service `setup` hook's — is built once during
3780
+ * bootstrap and cached, so the `exec` those hooks destructure can never
3781
+ * be the test's own. Without this, everything a service helper did was
3782
+ * invisible: `ctx.svc.<name>.<helper>()` shelling into a container
3783
+ * recorded no step, and a helper called inside a `ctx.poll` predicate
3784
+ * left the wait with nothing to render inside it (the poll keeps the
3785
+ * events of its last attempt — there were none). Undefined outside a
3786
+ * test, so bootstrap and project setup stay uninstrumented. */
3787
+ let RECORDING_EXEC:
3788
+ | ((
3789
+ service: string,
3790
+ command: string | string[],
3791
+ opts?: ExecOpts,
3792
+ ) => Promise<ExecResult>)
3793
+ | undefined;
3794
+
3795
+ /** `docker exec` with optional piped stdin. Array command = exact argv
3796
+ * (no shell); string = `sh -lc`. Non-zero exit is reported via
3797
+ * `exitCode`, never thrown.
3798
+ *
3799
+ * Records a step when a test is running (through `RECORDING_EXEC`, which
3800
+ * also captures the asciicast), and nothing at bootstrap. Either way the
3801
+ * caller gets the PLAIN `ExecResult`, never the test context's
3802
+ * provenance-wrapped one: component code is ordinary code written against
3803
+ * the declared type, and a wrapper is an object — `res.exitCode !== 0`
3804
+ * would be true for every exit code, and a client branching on `typeof`
3805
+ * would take the wrong arm. Provenance has nothing to point at here
3806
+ * anyway, since the helper's return value is not what the step recorded. */
3780
3807
  function componentExec(
3781
3808
  service: string,
3782
3809
  command: string | string[],
@@ -3785,10 +3812,13 @@ function componentExec(
3785
3812
  ): Promise<ExecResult> {
3786
3813
  assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3787
3814
  const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs;
3788
- return execInService(service, command, {
3815
+ const merged = {
3789
3816
  ...opts,
3790
3817
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3791
- });
3818
+ };
3819
+ const recording = RECORDING_EXEC;
3820
+ if (recording) return recording(service, command, merged).then(readRaw);
3821
+ return execInService(service, command, merged);
3792
3822
  }
3793
3823
 
3794
3824
  // ────────────────────────────────────────────────────────────────────────
@@ -4796,6 +4826,13 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
4796
4826
  parent,
4797
4827
  };
4798
4828
 
4829
+ // Let a component's `exec` record for the length of this case. The
4830
+ // context a `helpers` factory or a service `setup` hook holds was built
4831
+ // at bootstrap and carries `componentExec`, which reads this at call
4832
+ // time — so from here on a service helper's `docker exec` lands on the
4833
+ // timeline (and in the cast) exactly like the test's own `ctx.exec`.
4834
+ RECORDING_EXEC = recordedExec;
4835
+
4799
4836
  const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
4800
4837
  let timer: NodeJS.Timeout | undefined;
4801
4838
  const timedOut = new Promise<never>((_, reject) => {
@@ -4825,6 +4862,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
4825
4862
  };
4826
4863
  } finally {
4827
4864
  if (timer) clearTimeout(timer);
4865
+ RECORDING_EXEC = undefined;
4828
4866
  restoreFetch();
4829
4867
  restoreConsole();
4830
4868
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
package/src/index.ts CHANGED
@@ -2328,6 +2328,16 @@ function buildLocatorMatchers(
2328
2328
  const started = Date.now();
2329
2329
  const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
2330
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 };
2331
2341
  for (;;) {
2332
2342
  let satisfied: boolean;
2333
2343
  try {
@@ -2344,7 +2354,7 @@ function buildLocatorMatchers(
2344
2354
  }
2345
2355
  const passed = satisfied !== negated;
2346
2356
  if (passed) {
2347
- const sourceSeq = await probe.settle(matcher, Date.now() - started);
2357
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, undefined, settleOpts);
2348
2358
  recordAssertion({
2349
2359
  matcher,
2350
2360
  negated,
@@ -2358,7 +2368,7 @@ function buildLocatorMatchers(
2358
2368
  }
2359
2369
  if (Date.now() >= deadline) {
2360
2370
  const msg = describe(actual);
2361
- const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
2371
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
2362
2372
  recordAssertion({
2363
2373
  matcher,
2364
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 = {
package/src/recorder.ts CHANGED
@@ -473,6 +473,15 @@ export interface BrowserEvent extends BaseEvent {
473
473
  dy?: number;
474
474
  x?: number;
475
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;
476
485
  /** Screenshot image format. */
477
486
  format?: string;
478
487
  /** For `waitFor`: how many times the predicate was polled. */