@specific.dev/spectest 0.118.0 → 0.119.1

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/src/locator.ts CHANGED
@@ -29,7 +29,7 @@ import type { Wrapped } from "./inspect.js";
29
29
  import { rewriteLocatorErrorWithHints } from "./locator-errors.js";
30
30
  import { formatHints, nearMissHints, type HintTarget } from "./locator-hints.js";
31
31
  import { resolveExistingProjectPath } from "./project-files.js";
32
- import { truncateUtf8 } from "./recorder.js";
32
+ import { truncateUtf8, type RecordedBrowserLocatorPart } from "./recorder.js";
33
33
 
34
34
  /** Default deadline for a locator action/read's target to become actionable.
35
35
  * Playwright's own default is 30s — far too slow-failing for tests. This was
@@ -158,6 +158,22 @@ function chainOf(loc: Locator): Chain {
158
158
  return (loc as InternalLocator)[CHAIN];
159
159
  }
160
160
 
161
+ /** Keep locator structure (including regex flags and nested filters) in the
162
+ * event. The old selector field remains for classic/CLI compatibility. */
163
+ function recordedChain(chain: Chain): RecordedBrowserLocatorPart[] {
164
+ const argument = (v: unknown): unknown => {
165
+ if (v instanceof RegExp) return { regex: v.source, flags: v.flags };
166
+ if (isLocator(v)) return recordedChain(chainOf(v));
167
+ if (Array.isArray(v)) return v.map(argument);
168
+ if (v && typeof v === "object") {
169
+ return Object.fromEntries(Object.entries(v).filter(([, value]) => value !== undefined)
170
+ .map(([key, value]) => [key, argument(value)]));
171
+ }
172
+ return v;
173
+ };
174
+ return chain.steps.map(({ m, args }) => ({ method: m, args: args.map(argument) }));
175
+ }
176
+
161
177
  // ────────────────────────────────────────────────────────────────────────
162
178
  // Lowering: chain → live playwright Locator
163
179
  // ────────────────────────────────────────────────────────────────────────
@@ -553,8 +569,8 @@ async function stampActionPoint(
553
569
  }
554
570
  }
555
571
 
556
- /** Stamp the rrweb node id of the element a settled `expect(locator)` step is
557
- * about, so the dashboard can bring it into the replay's view.
572
+ /** Stamp the rrweb node id of a locator operation's target, so the dashboard
573
+ * can highlight it and bring it into the replay's view.
558
574
  *
559
575
  * Playwright's idea of "visible" is a non-empty box that isn't hidden — it
560
576
  * says nothing about the viewport, so an element below the fold passes
@@ -575,9 +591,11 @@ async function stampRevealTarget(
575
591
  rec: Partial<RecordableFields>,
576
592
  ): Promise<void> {
577
593
  try {
578
- const id = await loc.evaluate(
579
- (el) => {
580
- if (window.top !== window) return 0;
594
+ const id = await loc.evaluateAll(
595
+ (elements) => {
596
+ // Never wait for a missing element or pick an arbitrary list match.
597
+ if (elements.length !== 1 || window.top !== window) return 0;
598
+ const el = elements[0]!;
581
599
  // The bootstrap stashes rrweb's `record` here; `mirror` is its
582
600
  // node ↔ id map, shared by every snapshot it takes (see browser.ts).
583
601
  const w = window as unknown as {
@@ -588,8 +606,6 @@ async function stampRevealTarget(
588
606
  const id = getId.call(w.__spectestRec!.mirror, el);
589
607
  return typeof id === "number" && id > 0 ? id : 0;
590
608
  },
591
- undefined,
592
- { timeout: POINT_PROBE_MS },
593
609
  );
594
610
  if (id) rec.targetNodeId = id;
595
611
  } catch {
@@ -753,6 +769,7 @@ export function makeLocator(
753
769
  chain: Chain,
754
770
  ): Locator {
755
771
  const label = chainLabel(chain);
772
+ const target = recordedChain(chain);
756
773
  const extend = (step: Step): Locator =>
757
774
  makeLocator(backend, strategy, { steps: [...chain.steps, step] });
758
775
 
@@ -779,15 +796,38 @@ export function makeLocator(
779
796
  }
780
797
  };
781
798
 
799
+ // Capture identity before mutations (e.g. a press that removes its target).
800
+ // If an action auto-waits for an element that is not present yet, capture it
801
+ // afterwards instead. Both probes are immediate and best-effort; annotation
802
+ // must not change action errors, return values or locator strictness.
803
+ const withTarget = async <T>(
804
+ page: Page,
805
+ action: string,
806
+ rec: Partial<RecordableFields>,
807
+ fn: (loc: PWLocator, page: Page) => Promise<T>,
808
+ ): Promise<T> => {
809
+ const lowered = lower(page, chain);
810
+ const singleTarget = !["count", "all", "allTextContents", "allInnerTexts"].includes(action)
811
+ && !(action === "waitFor" && (rec.state === "hidden" || rec.state === "detached"));
812
+ if (singleTarget) await stampRevealTarget(lowered, rec);
813
+ try {
814
+ return await fn(lowered, page);
815
+ } finally {
816
+ if (singleTarget && !rec.targetNodeId) await stampRevealTarget(lowered, rec);
817
+ }
818
+ };
819
+
782
820
  // One recorded event, result NOT wrapped (void/action).
783
821
  const act = <T>(
784
822
  action: string,
785
823
  fields: Partial<RecordableFields>,
786
824
  fn: (loc: PWLocator, page: Page) => Promise<T>,
787
- ): Promise<T> =>
788
- backend.pageOp(action, { selector: label, ...fields }, (page) =>
789
- readable(page, () => fn(lower(page, chain), page)),
825
+ ): Promise<T> => {
826
+ const rec: Partial<RecordableFields> = { selector: label, target, ...fields };
827
+ return backend.pageOp(action, rec, (page) =>
828
+ readable(page, () => withTarget(page, action, rec, fn)),
790
829
  );
830
+ };
791
831
 
792
832
  // One recorded event whose fields the action itself finishes filling in:
793
833
  // `rec` is the very object the recorder spreads once `fn` resolves (the
@@ -797,7 +837,7 @@ export function makeLocator(
797
837
  action: string,
798
838
  fn: (loc: PWLocator, rec: Partial<RecordableFields>) => Promise<T>,
799
839
  ): Promise<T> => {
800
- const rec: Partial<RecordableFields> = { selector: label };
840
+ const rec: Partial<RecordableFields> = { selector: label, target };
801
841
  return backend.pageOp(action, rec, (page) =>
802
842
  readable(page, () => fn(lower(page, chain), rec)),
803
843
  );
@@ -808,13 +848,15 @@ export function makeLocator(
808
848
  action: string,
809
849
  fn: (loc: PWLocator) => Promise<T>,
810
850
  fields: Partial<RecordableFields> = {},
811
- ): Promise<Wrapped<T>> =>
812
- backend.pageOp(
851
+ ): Promise<Wrapped<T>> => {
852
+ const rec: Partial<RecordableFields> = { selector: label, target, ...fields };
853
+ return backend.pageOp(
813
854
  action,
814
- { selector: label, ...fields },
815
- (page) => readable(page, () => fn(lower(page, chain))),
855
+ rec,
856
+ (page) => readable(page, () => withTarget(page, action, rec, fn)),
816
857
  { wrap: true },
817
858
  ) as Promise<Wrapped<T>>;
859
+ };
818
860
 
819
861
  // Silent, non-recorded reads for expect(locator) matchers to poll.
820
862
  const probe: LocatorProbe = {
@@ -830,7 +872,7 @@ export function makeLocator(
830
872
  isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
831
873
  isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
832
874
  settle: async (action, waitedMs, error, opts) => {
833
- const fields: Partial<RecordableFields> = { selector: label };
875
+ const fields: Partial<RecordableFields> = { selector: label, target };
834
876
  if (opts && opts.reveal) {
835
877
  await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
836
878
  }
@@ -887,9 +929,9 @@ export function makeLocator(
887
929
  check: (opts) => act("check", {}, (l) => l.check({ timeout: opts?.timeout })),
888
930
  uncheck: (opts) => act("uncheck", {}, (l) => l.uncheck({ timeout: opts?.timeout })),
889
931
  setChecked: (checked, opts) =>
890
- act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
932
+ act("setChecked", { checked }, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
891
933
  selectOption: (values, opts) =>
892
- read("selectOption", (l) => l.selectOption(values as never, { timeout: opts?.timeout })),
934
+ read("selectOption", (l) => l.selectOption(values as never, { timeout: opts?.timeout }), { values }),
893
935
  setInputFiles: (files, opts) => {
894
936
  const { arg, names } = lowerInputFiles(files);
895
937
  return act("setInputFiles", { files: names }, (l) =>
@@ -902,7 +944,7 @@ export function makeLocator(
902
944
  scrollIntoViewIfNeeded: (opts) =>
903
945
  act("scrollIntoViewIfNeeded", {}, (l) => l.scrollIntoViewIfNeeded({ timeout: opts?.timeout })),
904
946
  dragTo: (target, opts) =>
905
- act("dragTo", {}, (l, page) => l.dragTo(lower(page, chainOf(target)), { timeout: opts?.timeout })),
947
+ act("dragTo", { destinationTarget: recordedChain(chainOf(target)) }, (l, page) => l.dragTo(lower(page, chainOf(target)), { timeout: opts?.timeout })),
906
948
 
907
949
  // reads
908
950
  textContent: (opts) => read("textContent", (l) => l.textContent({ timeout: opts?.timeout })),
@@ -931,16 +973,15 @@ export function makeLocator(
931
973
  for (let i = 0; i < n; i++) out.push(extend({ m: "nth", args: [i] }));
932
974
  return out;
933
975
  },
934
- evaluate: (description, fn, arg) =>
935
- backend.pageOp(
936
- "evaluate",
937
- { selector: label, description },
938
- (page) => readable(page, () => lower(page, chain).evaluate(fn as never, arg)),
939
- { wrap: true },
940
- ) as Promise<Wrapped<never>>,
976
+ evaluate: (description, fn, arg) => {
977
+ const script = truncateUtf8(fn.toString());
978
+ return read("evaluate", (l) => l.evaluate(fn as never, arg), {
979
+ description, script: script.value, scriptTruncated: script.truncated,
980
+ }) as Promise<Wrapped<never>>;
981
+ },
941
982
 
942
983
  waitFor: (opts) =>
943
- act("waitFor", {}, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
984
+ act("waitFor", { state: opts?.state ?? "visible" }, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
944
985
  };
945
986
  // Attach the silent-read probe under its symbol (kept off the typed literal
946
987
  // so declaration order stays simple).
package/src/recorder.ts CHANGED
@@ -458,9 +458,18 @@ export interface EnvEvent extends BaseEvent {
458
458
  // "check", "hover", "textContent", "inputValue", "count", "screenshot".
459
459
  export type BrowserAction = string;
460
460
 
461
+ /** JSON-safe locator syntax. Regexes use {regex, flags}, nested locators
462
+ * use another chain. Presentation is determined by the dashboard. */
463
+ export interface RecordedBrowserLocatorPart {
464
+ method: string;
465
+ args: unknown[];
466
+ }
467
+
461
468
  export interface BrowserEvent extends BaseEvent {
462
469
  kind: "browser";
463
470
  action: BrowserAction;
471
+ target?: RecordedBrowserLocatorPart[];
472
+ destinationTarget?: RecordedBrowserLocatorPart[];
464
473
  /** Target URL (navigate). */
465
474
  url?: string;
466
475
  /** CSS selector (click, scrollTo). */
@@ -480,6 +489,10 @@ export interface BrowserEvent extends BaseEvent {
480
489
  /** File names given to an `<input type="file">` (setInputFiles). Names
481
490
  * only — a built file's bytes stay out of the timeline. */
482
491
  files?: string[];
492
+ /** Arguments used by the dashboard to describe the operation completely. */
493
+ checked?: boolean;
494
+ values?: string | string[] | { label?: string; value?: string; index?: number };
495
+ state?: string;
483
496
  /** Scroll/click coordinates. */
484
497
  dx?: number;
485
498
  dy?: number;