@specific.dev/spectest 0.50.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/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/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.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/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/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. */