@specific.dev/spectest 0.118.0 → 0.119.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 +10 -1
- package/dist/browser.js +1 -1
- package/dist/harness/network.d.ts +18 -0
- package/dist/harness/network.js +194 -0
- package/dist/locator.js +64 -14
- package/dist/recorder.d.ts +16 -0
- package/package.json +1 -1
- package/src/browser.ts +7 -2
- package/src/harness/network.test.ts +265 -0
- package/src/harness/network.ts +178 -0
- package/src/locator-recording.test.ts +150 -0
- package/src/locator.ts +69 -28
- package/src/recorder.ts +13 -0
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
|
|
557
|
-
*
|
|
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.
|
|
579
|
-
(
|
|
580
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
851
|
+
): Promise<Wrapped<T>> => {
|
|
852
|
+
const rec: Partial<RecordableFields> = { selector: label, target, ...fields };
|
|
853
|
+
return backend.pageOp(
|
|
813
854
|
action,
|
|
814
|
-
|
|
815
|
-
(page) => readable(page, () =>
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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;
|