@specific.dev/spectest 0.64.0 → 0.67.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.js +42 -1
- package/dist/components/supabase.js +265 -18
- package/dist/daemon.js +83 -31
- package/dist/harness/intercept.d.ts +22 -0
- package/dist/harness/intercept.js +29 -0
- package/dist/index.d.ts +52 -16
- package/dist/index.js +76 -17
- package/dist/locator-errors.d.ts +19 -10
- package/dist/locator-errors.js +80 -20
- package/dist/locator-hints.d.ts +96 -0
- package/dist/locator-hints.js +403 -0
- package/dist/locator.d.ts +22 -0
- package/dist/locator.js +63 -9
- package/dist/page-snapshot.d.ts +42 -0
- package/dist/page-snapshot.js +149 -0
- package/dist/recorder.d.ts +16 -0
- package/dist/text-match.d.ts +39 -0
- package/dist/text-match.js +239 -0
- package/package.json +1 -1
- package/src/browser.ts +43 -1
- package/src/components/supabase.ts +269 -18
- package/src/daemon.ts +85 -29
- package/src/harness/intercept.test.ts +36 -0
- package/src/harness/intercept.ts +40 -0
- package/src/index.ts +159 -32
- package/src/locator-errors.test.ts +99 -11
- package/src/locator-errors.ts +98 -19
- package/src/locator-hints.test.ts +188 -0
- package/src/locator-hints.ts +514 -0
- package/src/locator.ts +72 -9
- package/src/page-snapshot.test.ts +100 -0
- package/src/page-snapshot.ts +180 -0
- package/src/recorder.ts +16 -0
- package/src/text-match.test.ts +132 -0
- package/src/text-match.ts +285 -0
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, type Mcp, type McpOption
|
|
|
14
14
|
import type { Mcp, McpOptions } from "./mcp.js";
|
|
15
15
|
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
|
|
16
16
|
import type { Locator } from "./locator.js";
|
|
17
|
+
import { type TextMatchOptions } from "./text-match.js";
|
|
17
18
|
export type { UrlPattern } from "./url-match.js";
|
|
18
19
|
import type { UrlPattern } from "./url-match.js";
|
|
19
20
|
export type { Mobile, MobileApp } from "./mobile.js";
|
|
@@ -34,7 +35,8 @@ export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
|
34
35
|
*/
|
|
35
36
|
export interface Interception {
|
|
36
37
|
hostname: string;
|
|
37
|
-
/** The normalised mount path (`"/"` when
|
|
38
|
+
/** The normalised mount path parsed off the target (`"/"` when the
|
|
39
|
+
* target was a bare hostname). */
|
|
38
40
|
path: string;
|
|
39
41
|
/** How many requests the interceptor has seen. Provenance-wrapped, so an
|
|
40
42
|
* `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
|
|
@@ -381,12 +383,23 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
381
383
|
* make your **own** backend misbehave for one test: force a 500 from an
|
|
382
384
|
* API route, add latency, fail twice then pass, or just count calls.
|
|
383
385
|
*
|
|
386
|
+
* `target` is the hostname, optionally with a mount path:
|
|
387
|
+
* `"app.test"` claims every request to that host, `"app.test/api/sync"`
|
|
388
|
+
* claims that path and everything below it (like `app.use(path, fn)`).
|
|
389
|
+
*
|
|
390
|
+
* `description` is what the environment now **does**, in the present
|
|
391
|
+
* tense — it is the step's whole title in the timeline and in the CLI's
|
|
392
|
+
* failure detail, so it is what tells a reader why the page under test
|
|
393
|
+
* went to its error state. Write the effect, not the act of intercepting
|
|
394
|
+
* (the step is already labelled INTERCEPT) and not the test's goal:
|
|
395
|
+
* `"the sync endpoint returns 503"`, not `"intercept sync"`, `"mock the
|
|
396
|
+
* API"` or `"test error handling"`. The handler's own source is shown
|
|
397
|
+
* under it, so the description carries the intent, never the mechanism.
|
|
398
|
+
*
|
|
384
399
|
* Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
|
|
385
400
|
* response (a proxied service, or a fake). Return a `Response` to answer
|
|
386
401
|
* yourself, `next()` to pass through, or change what `next()` returned.
|
|
387
|
-
*
|
|
388
|
-
* (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
|
|
389
|
-
* registration order.
|
|
402
|
+
* Interceptors run in registration order.
|
|
390
403
|
*
|
|
391
404
|
* Only traffic that reaches the daemon can be intercepted: the browser,
|
|
392
405
|
* `ctx.fetch`, and any container that calls the hostname — so the service
|
|
@@ -400,16 +413,18 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
400
413
|
* `remove()`. Every request it sees is recorded under the intercept step.
|
|
401
414
|
*
|
|
402
415
|
* ```ts
|
|
403
|
-
* const outage = ctx.intercept(
|
|
404
|
-
*
|
|
416
|
+
* const outage = ctx.intercept(
|
|
417
|
+
* "api.test/functions/v1/sync",
|
|
418
|
+
* "the sync function returns 500",
|
|
419
|
+
* () => new Response("boom", { status: 500 }),
|
|
420
|
+
* );
|
|
405
421
|
* await page.getByRole("button", { name: "Sync" }).click();
|
|
406
422
|
* await expect(page.getByText("Retry")).toBeVisible();
|
|
407
423
|
* expect(outage.calls).toBe(1);
|
|
408
424
|
* outage.remove(); // the retry now reaches the real function
|
|
409
425
|
* ```
|
|
410
426
|
*/
|
|
411
|
-
intercept(
|
|
412
|
-
intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
|
|
427
|
+
intercept(target: string, description: string, handler: InterceptHandler): Interception;
|
|
413
428
|
/**
|
|
414
429
|
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
415
430
|
*
|
|
@@ -1672,6 +1687,14 @@ interface Matchers {
|
|
|
1672
1687
|
export interface Expectation extends Matchers {
|
|
1673
1688
|
not: Matchers;
|
|
1674
1689
|
}
|
|
1690
|
+
/** Options for the text matchers — playwright's set, same names, same
|
|
1691
|
+
* meanings. */
|
|
1692
|
+
export interface TextMatcherOptions extends TextMatchOptions {
|
|
1693
|
+
timeout?: number;
|
|
1694
|
+
/** Read `innerText` (what the page renders — hidden elements dropped,
|
|
1695
|
+
* `text-transform` applied) instead of `textContent`. */
|
|
1696
|
+
useInnerText?: boolean;
|
|
1697
|
+
}
|
|
1675
1698
|
/**
|
|
1676
1699
|
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
1677
1700
|
* `expect(locator)` matchers. Each polls the element until it passes or a
|
|
@@ -1687,14 +1710,27 @@ export interface LocatorMatchers {
|
|
|
1687
1710
|
toBeHidden(opts?: {
|
|
1688
1711
|
timeout?: number;
|
|
1689
1712
|
}): Promise<void>;
|
|
1690
|
-
/**
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1713
|
+
/**
|
|
1714
|
+
* The element's text equals `expected`, or matches it when it is a RegExp.
|
|
1715
|
+
*
|
|
1716
|
+
* **Whitespace is normalized on both sides**, exactly as playwright does
|
|
1717
|
+
* it: the text is trimmed and every run of whitespace becomes one space.
|
|
1718
|
+
* So a typed space matches the no-break space (U+00A0) that
|
|
1719
|
+
* `Intl.NumberFormat` puts between thousands, and a value wrapped across
|
|
1720
|
+
* two lines in the markup matches the one-line string you wrote.
|
|
1721
|
+
*
|
|
1722
|
+
* Pass an **array** to assert over every element the locator matches, in
|
|
1723
|
+
* order; the counts must then agree.
|
|
1724
|
+
*/
|
|
1725
|
+
toHaveText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1726
|
+
/**
|
|
1727
|
+
* The element's text contains `expected` — a substring, or a RegExp tested
|
|
1728
|
+
* against the text. Whitespace is normalized as in {@link toHaveText}.
|
|
1729
|
+
*
|
|
1730
|
+
* An **array** asserts that the matched elements contain these texts in
|
|
1731
|
+
* order; unlike `toHaveText` extra elements between them are allowed.
|
|
1732
|
+
*/
|
|
1733
|
+
toContainText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1698
1734
|
/** The input's value equals `expected` (or matches a RegExp). */
|
|
1699
1735
|
toHaveValue(expected: string | RegExp, opts?: {
|
|
1700
1736
|
timeout?: number;
|
package/dist/index.js
CHANGED
|
@@ -39,7 +39,8 @@ export { S3Client } from "./s3.js";
|
|
|
39
39
|
// own browser. See `mcp.ts`.
|
|
40
40
|
export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
|
|
41
41
|
import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
|
|
42
|
-
import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
|
|
42
|
+
import { classifyLocatorFailure, formatWaited, locatorFailureMessage, } from "./locator-errors.js";
|
|
43
|
+
import { containsText, containsTextArray, escapeInvisible, matchesText, matchesTextArray, textDifferenceNote, } from "./text-match.js";
|
|
43
44
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
44
45
|
import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
|
|
45
46
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
@@ -674,14 +675,33 @@ function buildLocatorMatchers(loc, negated, message) {
|
|
|
674
675
|
// matches a hidden element fail identically — so the count is read once,
|
|
675
676
|
// at failure time only. `toHaveCount` is left alone: "expected count 2,
|
|
676
677
|
// got 0" already says it, and better.
|
|
678
|
+
//
|
|
679
|
+
// Either way the message ends with the near-miss block — what the page
|
|
680
|
+
// holds that is close to what was asked for (see locator-hints.ts) — so
|
|
681
|
+
// the author does not have to run again to find out.
|
|
682
|
+
const nearMiss = async () => {
|
|
683
|
+
try {
|
|
684
|
+
return await probe.nearMiss();
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return "";
|
|
688
|
+
}
|
|
689
|
+
};
|
|
677
690
|
const elementFailure = async (err) => {
|
|
678
|
-
if (err !== undefined)
|
|
679
|
-
|
|
691
|
+
if (err !== undefined) {
|
|
692
|
+
const failure = classifyLocatorFailure(probe.label, err);
|
|
693
|
+
if (!failure)
|
|
694
|
+
return undefined;
|
|
695
|
+
return failure.kind === "no-match"
|
|
696
|
+
? failure.message + (await nearMiss())
|
|
697
|
+
: failure.message;
|
|
698
|
+
}
|
|
680
699
|
if (expectsGone || matcher === "toHaveCount")
|
|
681
700
|
return undefined;
|
|
682
701
|
try {
|
|
683
702
|
if ((await probe.count()) === 0) {
|
|
684
|
-
return `No element matches ${probe.label} (waited ${formatWaited(budget)})
|
|
703
|
+
return (`No element matches ${probe.label} (waited ${formatWaited(budget)})` +
|
|
704
|
+
(await nearMiss()));
|
|
685
705
|
}
|
|
686
706
|
}
|
|
687
707
|
catch {
|
|
@@ -754,7 +774,22 @@ function buildLocatorMatchers(loc, negated, message) {
|
|
|
754
774
|
}
|
|
755
775
|
};
|
|
756
776
|
const not = negated ? " not" : "";
|
|
757
|
-
|
|
777
|
+
// The element's text, read the way the caller asked for it. `useInnerText`
|
|
778
|
+
// is playwright's option and means the same here: what the page renders,
|
|
779
|
+
// rather than every character in the subtree.
|
|
780
|
+
const readText = async (opts) => opts?.useInnerText
|
|
781
|
+
? await probe.innerText(opts.timeout)
|
|
782
|
+
: ((await probe.textContent(opts?.timeout)) ?? "");
|
|
783
|
+
const readTexts = async (opts) => opts?.useInnerText ? await probe.allInnerTexts() : await probe.allTextContents();
|
|
784
|
+
const isArrayExpectation = (v) => Array.isArray(v);
|
|
785
|
+
/** Expected values render as themselves; a RegExp renders as its source. */
|
|
786
|
+
const fmtExpected = (v) => v instanceof RegExp
|
|
787
|
+
? String(v)
|
|
788
|
+
: Array.isArray(v)
|
|
789
|
+
? `[${v.map(fmtExpected).join(", ")}]`
|
|
790
|
+
: fmt(v);
|
|
791
|
+
/** What goes on the assertion event: a RegExp cannot be serialized. */
|
|
792
|
+
const expectedValue = (v) => (v instanceof RegExp ? String(v) : Array.isArray(v) ? v.map(expectedValue) : v);
|
|
758
793
|
return {
|
|
759
794
|
toBeVisible: (opts) => run("toBeVisible", opts?.timeout, async () => {
|
|
760
795
|
const v = await probe.isVisible();
|
|
@@ -765,17 +800,34 @@ function buildLocatorMatchers(loc, negated, message) {
|
|
|
765
800
|
return { satisfied: !v, actual: v };
|
|
766
801
|
}, () => `expected ${probe.label}${not} to be hidden`),
|
|
767
802
|
toHaveText: (expected, opts) => run("toHaveText", opts?.timeout, async () => {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
803
|
+
if (isArrayExpectation(expected)) {
|
|
804
|
+
const texts = await readTexts(opts);
|
|
805
|
+
return { satisfied: matchesTextArray(texts, expected, opts), actual: texts };
|
|
806
|
+
}
|
|
807
|
+
const t = await readText(opts);
|
|
808
|
+
return { satisfied: matchesText(t, expected, opts), actual: t };
|
|
809
|
+
}, (a) => `expected ${probe.label}${not} to have text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
810
|
+
textDifferenceNote(a, expected, "equal"), expectedValue(expected)),
|
|
771
811
|
toContainText: (expected, opts) => run("toContainText", opts?.timeout, async () => {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
812
|
+
if (isArrayExpectation(expected)) {
|
|
813
|
+
const texts = await readTexts(opts);
|
|
814
|
+
return { satisfied: containsTextArray(texts, expected, opts), actual: texts };
|
|
815
|
+
}
|
|
816
|
+
const t = await readText(opts);
|
|
817
|
+
return { satisfied: containsText(t, expected, opts), actual: t };
|
|
818
|
+
}, (a) => `expected ${probe.label}${not} to contain text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
819
|
+
textDifferenceNote(a, expected, "contains"), expectedValue(expected)),
|
|
820
|
+
// Deliberately NOT whitespace-normalized: an input's value is data the
|
|
821
|
+
// user typed or the app set, not rendered text, and playwright compares
|
|
822
|
+
// it exactly for the same reason. When that exactness is what failed,
|
|
823
|
+
// `textDifferenceNote` says so instead of leaving two identical-looking
|
|
824
|
+
// strings on screen.
|
|
775
825
|
toHaveValue: (expected, opts) => run("toHaveValue", opts?.timeout, async () => {
|
|
776
826
|
const v = await probe.inputValue(opts?.timeout);
|
|
777
|
-
|
|
778
|
-
|
|
827
|
+
const satisfied = expected instanceof RegExp ? expected.test(v) : v === expected;
|
|
828
|
+
return { satisfied, actual: v };
|
|
829
|
+
}, (a) => `expected ${probe.label}${not} to have value ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
830
|
+
textDifferenceNote(a, expected, "equal"), expectedValue(expected)),
|
|
779
831
|
toHaveCount: (expected, opts) => run("toHaveCount", opts?.timeout, async () => {
|
|
780
832
|
const c = await probe.count();
|
|
781
833
|
return { satisfied: c === expected, actual: c };
|
|
@@ -949,7 +1001,8 @@ function buildCore(actual, tag, negated, message, pendingError) {
|
|
|
949
1001
|
return {
|
|
950
1002
|
toBe(expected) {
|
|
951
1003
|
const exp = readRaw(expected);
|
|
952
|
-
run("toBe", Object.is(actual, exp), `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}
|
|
1004
|
+
run("toBe", Object.is(actual, exp), `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}` +
|
|
1005
|
+
textDifferenceNote(actual, exp, "equal"), exp);
|
|
953
1006
|
},
|
|
954
1007
|
toEqual(expected) {
|
|
955
1008
|
const exp = readRaw(expected);
|
|
@@ -960,7 +1013,8 @@ function buildCore(actual, tag, negated, message, pendingError) {
|
|
|
960
1013
|
catch {
|
|
961
1014
|
equal = false;
|
|
962
1015
|
}
|
|
963
|
-
run("toEqual", equal, `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}
|
|
1016
|
+
run("toEqual", equal, `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}` +
|
|
1017
|
+
textDifferenceNote(actual, exp, "equal"), exp);
|
|
964
1018
|
},
|
|
965
1019
|
toBeTruthy() {
|
|
966
1020
|
run("toBeTruthy", !!actual, `expected ${fmt(actual)}${negated ? " not" : ""} to be truthy`);
|
|
@@ -997,7 +1051,8 @@ function buildCore(actual, tag, negated, message, pendingError) {
|
|
|
997
1051
|
}
|
|
998
1052
|
});
|
|
999
1053
|
}
|
|
1000
|
-
run("toContain", contained, `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}
|
|
1054
|
+
run("toContain", contained, `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}` +
|
|
1055
|
+
textDifferenceNote(actual, exp, "contains"), exp);
|
|
1001
1056
|
},
|
|
1002
1057
|
toMatch(re) {
|
|
1003
1058
|
run("toMatch", typeof actual === "string" && re.test(actual), `expected ${fmt(actual)}${negated ? " not" : ""} to match ${re}`, String(re));
|
|
@@ -1032,8 +1087,12 @@ function buildCore(actual, tag, negated, message, pendingError) {
|
|
|
1032
1087
|
};
|
|
1033
1088
|
}
|
|
1034
1089
|
function fmt(v) {
|
|
1090
|
+
// A string is escaped down to its invisible characters, so two values that
|
|
1091
|
+
// print the same on screen do not print the same in a failure. Without it a
|
|
1092
|
+
// no-break space and a space are the same three characters wide, and the
|
|
1093
|
+
// message reads "expected "15 000 kr", got "15 000 kr"".
|
|
1035
1094
|
if (typeof v === "string")
|
|
1036
|
-
return JSON.stringify(v);
|
|
1095
|
+
return escapeInvisible(JSON.stringify(v));
|
|
1037
1096
|
if (typeof v === "bigint")
|
|
1038
1097
|
return `${v}n`;
|
|
1039
1098
|
if (v === undefined)
|
package/dist/locator-errors.d.ts
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
/** A wait's duration, in the units a reader thinks in: "800ms", "5s", "1.5s". */
|
|
2
2
|
export declare function formatWaited(ms: number): string;
|
|
3
|
+
/** How a locator failed. `no-match` is the only kind worth asking the page
|
|
4
|
+
* about (see locator-hints.ts) — for every other kind the element was found
|
|
5
|
+
* and near misses would be noise. */
|
|
6
|
+
export type LocatorFailureKind = "no-match" | "state" | "strict";
|
|
7
|
+
export interface LocatorFailure {
|
|
8
|
+
message: string;
|
|
9
|
+
kind: LocatorFailureKind;
|
|
10
|
+
}
|
|
3
11
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
12
|
+
* What to fail with, or `undefined` when `err` is not a locator failure we
|
|
13
|
+
* understand (in which case the caller must leave it alone).
|
|
6
14
|
*
|
|
7
15
|
* `label` is the locator's human chain label, the same string the timeline
|
|
8
16
|
* step shows.
|
|
9
17
|
*/
|
|
18
|
+
export declare function classifyLocatorFailure(label: string, err: unknown): LocatorFailure | undefined;
|
|
19
|
+
/** The sentence alone. See {@link classifyLocatorFailure}. */
|
|
10
20
|
export declare function locatorFailureMessage(label: string, err: unknown): string | undefined;
|
|
11
21
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
22
|
+
* The rewrite above, plus the near-miss block for the one failure that can
|
|
23
|
+
* carry one — a locator that matched nothing.
|
|
14
24
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* then the timeout wall right under it.
|
|
25
|
+
* `hints` is only called in that case, so a page query is never made for a
|
|
26
|
+
* failure it could not explain (a disabled element, a covered one, an
|
|
27
|
+
* ambiguous match). It is expected to be best-effort itself; anything it
|
|
28
|
+
* throws leaves the plain sentence standing.
|
|
20
29
|
*/
|
|
21
|
-
export declare function
|
|
30
|
+
export declare function rewriteLocatorErrorWithHints(err: unknown, label: string, hints: () => Promise<string>): Promise<unknown>;
|
package/dist/locator-errors.js
CHANGED
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
// as an infrastructure problem and says nothing about the page.
|
|
9
9
|
//
|
|
10
10
|
// This module turns those into one sentence that names the element and what
|
|
11
|
-
// was wrong with it.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// was wrong with it. A strict-mode violation — the opposite failure, where the
|
|
12
|
+
// locator matched too much — is rewritten too, keeping playwright's own list
|
|
13
|
+
// of matches, which is the useful half of it. Anything else (a closed page, an
|
|
14
|
+
// assertion of ours) is passed through untouched: the rule is that a message is
|
|
15
|
+
// only ever replaced when we have something better to say.
|
|
16
|
+
//
|
|
17
|
+
// `classifyLocatorFailure` also says WHICH failure it was, because only one of
|
|
18
|
+
// them — a locator that matched nothing — can be explained further by asking
|
|
19
|
+
// the page what it holds (see locator-hints.ts).
|
|
15
20
|
//
|
|
16
21
|
// The call log is read from the error's own `log` array (playwright attaches
|
|
17
22
|
// it) and falls back to parsing the message, since only the message survives
|
|
@@ -80,38 +85,72 @@ function actionabilityReason(log) {
|
|
|
80
85
|
}
|
|
81
86
|
return undefined;
|
|
82
87
|
}
|
|
88
|
+
/** Playwright's strict-mode violation: the locator matched more than one
|
|
89
|
+
* element and refused to act. It is an ordinary `Error`, not a
|
|
90
|
+
* `TimeoutError`, and its message already lists the matches — which is the
|
|
91
|
+
* useful half, so it is kept and only the wall of text around it is cut. */
|
|
92
|
+
function parseStrictViolation(err, label) {
|
|
93
|
+
const e = err;
|
|
94
|
+
if (!e || typeof e.message !== "string")
|
|
95
|
+
return undefined;
|
|
96
|
+
if (!e.message.includes("strict mode violation"))
|
|
97
|
+
return undefined;
|
|
98
|
+
const m = /resolved to (\d+) elements/.exec(e.message);
|
|
99
|
+
if (!m)
|
|
100
|
+
return undefined;
|
|
101
|
+
const matches = e.message
|
|
102
|
+
.split("\n")
|
|
103
|
+
.filter((l) => /^\s*\d+\)\s/.test(l))
|
|
104
|
+
.slice(0, 3)
|
|
105
|
+
.map((l) => ` - ${l.trim().replace(/^\d+\)\s*/, "")}`);
|
|
106
|
+
const head = `${label} matches ${m[1]} elements, so it is ambiguous` +
|
|
107
|
+
" — narrow it with .first(), .nth(i), .filter({ hasText }) or a more specific query";
|
|
108
|
+
return {
|
|
109
|
+
kind: "strict",
|
|
110
|
+
message: matches.length ? `${head}:\n${matches.join("\n")}` : head,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
83
113
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
114
|
+
* What to fail with, or `undefined` when `err` is not a locator failure we
|
|
115
|
+
* understand (in which case the caller must leave it alone).
|
|
86
116
|
*
|
|
87
117
|
* `label` is the locator's human chain label, the same string the timeline
|
|
88
118
|
* step shows.
|
|
89
119
|
*/
|
|
90
|
-
export function
|
|
120
|
+
export function classifyLocatorFailure(label, err) {
|
|
121
|
+
const strict = parseStrictViolation(err, label);
|
|
122
|
+
if (strict)
|
|
123
|
+
return strict;
|
|
91
124
|
const detail = parseTimeout(err);
|
|
92
125
|
if (!detail)
|
|
93
126
|
return undefined;
|
|
94
127
|
const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
|
|
95
|
-
if (!resolved(detail.log))
|
|
96
|
-
return `No element matches ${label} ${waited}
|
|
128
|
+
if (!resolved(detail.log)) {
|
|
129
|
+
return { kind: "no-match", message: `No element matches ${label} ${waited}` };
|
|
130
|
+
}
|
|
97
131
|
// It matched, so the failure is about the element's state.
|
|
132
|
+
const state = (message) => ({ kind: "state", message });
|
|
98
133
|
switch (waitedForState(detail.log)) {
|
|
99
134
|
case "hidden":
|
|
100
|
-
return `Element ${label} is still visible ${waited}
|
|
135
|
+
return state(`Element ${label} is still visible ${waited}`);
|
|
101
136
|
case "detached":
|
|
102
|
-
return `Element ${label} is still attached to the page ${waited}
|
|
137
|
+
return state(`Element ${label} is still attached to the page ${waited}`);
|
|
103
138
|
case "visible":
|
|
104
|
-
return `Element ${label} is not visible ${waited}
|
|
139
|
+
return state(`Element ${label} is not visible ${waited}`);
|
|
105
140
|
default:
|
|
106
141
|
break;
|
|
107
142
|
}
|
|
108
143
|
const reason = actionabilityReason(detail.log);
|
|
109
|
-
return reason
|
|
144
|
+
return state(reason
|
|
110
145
|
? `Element ${label} ${reason} ${waited}`
|
|
111
|
-
: `Element ${label} never became ready for this action ${waited}
|
|
146
|
+
: `Element ${label} never became ready for this action ${waited}`);
|
|
147
|
+
}
|
|
148
|
+
/** The sentence alone. See {@link classifyLocatorFailure}. */
|
|
149
|
+
export function locatorFailureMessage(label, err) {
|
|
150
|
+
return classifyLocatorFailure(label, err)?.message;
|
|
112
151
|
}
|
|
113
152
|
/**
|
|
114
|
-
* Replace a locator
|
|
153
|
+
* Replace a locator failure's message in place and hand the error back, so the
|
|
115
154
|
* caller can `throw` it unchanged in every other respect.
|
|
116
155
|
*
|
|
117
156
|
* The error is mutated rather than wrapped: its stack holds the frames of the
|
|
@@ -120,11 +159,8 @@ export function locatorFailureMessage(label, err) {
|
|
|
120
159
|
* too — otherwise the CLI's failure block would print the friendly message and
|
|
121
160
|
* then the timeout wall right under it.
|
|
122
161
|
*/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (message === undefined)
|
|
126
|
-
return err;
|
|
127
|
-
const e = err;
|
|
162
|
+
/** Put `message` on `err`, in the message and in the stack's copy of it. */
|
|
163
|
+
function setErrorMessage(e, message) {
|
|
128
164
|
const old = e.message;
|
|
129
165
|
if (typeof e.stack === "string") {
|
|
130
166
|
for (const [header, replacement] of [
|
|
@@ -140,3 +176,27 @@ export function rewriteLocatorError(err, label) {
|
|
|
140
176
|
e.message = message;
|
|
141
177
|
return e;
|
|
142
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* The rewrite above, plus the near-miss block for the one failure that can
|
|
181
|
+
* carry one — a locator that matched nothing.
|
|
182
|
+
*
|
|
183
|
+
* `hints` is only called in that case, so a page query is never made for a
|
|
184
|
+
* failure it could not explain (a disabled element, a covered one, an
|
|
185
|
+
* ambiguous match). It is expected to be best-effort itself; anything it
|
|
186
|
+
* throws leaves the plain sentence standing.
|
|
187
|
+
*/
|
|
188
|
+
export async function rewriteLocatorErrorWithHints(err, label, hints) {
|
|
189
|
+
const failure = classifyLocatorFailure(label, err);
|
|
190
|
+
if (!failure)
|
|
191
|
+
return err;
|
|
192
|
+
let message = failure.message;
|
|
193
|
+
if (failure.kind === "no-match") {
|
|
194
|
+
try {
|
|
195
|
+
message += await hints();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* A diagnostic must never replace the failure it explains. */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return setErrorMessage(err, message);
|
|
202
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
/** What the author asked for — the last selecting step of a failed chain. */
|
|
3
|
+
export interface HintTarget {
|
|
4
|
+
kind: "role" | "text" | "label" | "placeholder" | "altText" | "title" | "testId" | "other";
|
|
5
|
+
/** The ARIA role, for `kind: "role"`. */
|
|
6
|
+
role?: string;
|
|
7
|
+
/** The string the author asked for. Absent for a RegExp query, which we
|
|
8
|
+
* make no suggestions from — a near-miss on a pattern is not a near-miss. */
|
|
9
|
+
query?: string;
|
|
10
|
+
}
|
|
11
|
+
/** One role/name pair from the page's accessibility tree. */
|
|
12
|
+
export interface AriaCandidate {
|
|
13
|
+
role: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
/** An interactive element whose visible text is not its accessible name. */
|
|
17
|
+
export interface NameMismatch {
|
|
18
|
+
tag: string;
|
|
19
|
+
text: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** The attribute the name came from: `aria-label`, `aria-labelledby`, … */
|
|
22
|
+
from: string;
|
|
23
|
+
}
|
|
24
|
+
/** What the DOM pass brings back. Every list is deduplicated and capped. */
|
|
25
|
+
export interface DomFacts {
|
|
26
|
+
texts: string[];
|
|
27
|
+
testIds: string[];
|
|
28
|
+
placeholders: string[];
|
|
29
|
+
labels: string[];
|
|
30
|
+
titles: string[];
|
|
31
|
+
alts: string[];
|
|
32
|
+
mismatches: NameMismatch[];
|
|
33
|
+
}
|
|
34
|
+
export declare const EMPTY_DOM_FACTS: DomFacts;
|
|
35
|
+
/** The locator a suggestion proposes, as data — so the ranking stays pure and
|
|
36
|
+
* testable and only `nearMissHints` touches a page. */
|
|
37
|
+
export type SuggestedLocator = {
|
|
38
|
+
m: "getByRole";
|
|
39
|
+
role: string;
|
|
40
|
+
name: string;
|
|
41
|
+
} | {
|
|
42
|
+
m: "getByText";
|
|
43
|
+
text: string;
|
|
44
|
+
} | {
|
|
45
|
+
m: "getByLabel";
|
|
46
|
+
text: string;
|
|
47
|
+
} | {
|
|
48
|
+
m: "getByPlaceholder";
|
|
49
|
+
text: string;
|
|
50
|
+
} | {
|
|
51
|
+
m: "getByAltText";
|
|
52
|
+
text: string;
|
|
53
|
+
} | {
|
|
54
|
+
m: "getByTitle";
|
|
55
|
+
text: string;
|
|
56
|
+
} | {
|
|
57
|
+
m: "getByTestId";
|
|
58
|
+
id: string;
|
|
59
|
+
};
|
|
60
|
+
export interface Suggestion {
|
|
61
|
+
/** What is on the page, in the reader's terms. */
|
|
62
|
+
fact: string;
|
|
63
|
+
/** The locator that selects it. */
|
|
64
|
+
locator: SuggestedLocator;
|
|
65
|
+
/** Higher is closer. Ranking only; never shown. */
|
|
66
|
+
score: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How close `candidate` is to what the author asked for, from 0 (unrelated)
|
|
70
|
+
* to 4 (the same string). Everything at or above `NEAR` is worth showing.
|
|
71
|
+
*/
|
|
72
|
+
export declare function closeness(query: string, candidate: string): number;
|
|
73
|
+
/** Render one verified suggestion as the line the author reads. */
|
|
74
|
+
export declare function suggestionLine(s: Suggestion, matches: number): string;
|
|
75
|
+
/**
|
|
76
|
+
* Rank what the page holds against what the author asked for. Pure: the
|
|
77
|
+
* caller collects `aria`/`dom` and verifies the winners.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildSuggestions(target: HintTarget, aria: AriaCandidate[], dom: DomFacts): Suggestion[];
|
|
80
|
+
/**
|
|
81
|
+
* Pull `- button "Save"` lines out of an ARIA snapshot.
|
|
82
|
+
*
|
|
83
|
+
* The snapshot is YAML, but only its leading token carries what we need, so
|
|
84
|
+
* this reads it line by line rather than pulling in a parser. A line we do not
|
|
85
|
+
* recognise is skipped — the worst case is one fewer suggestion.
|
|
86
|
+
*/
|
|
87
|
+
export declare function parseAriaSnapshot(yaml: string): AriaCandidate[];
|
|
88
|
+
/**
|
|
89
|
+
* The lines to append to a "no element matches" failure — at most
|
|
90
|
+
* {@link MAX_SUGGESTIONS}, each verified against the live page, and empty
|
|
91
|
+
* whenever the page holds nothing close.
|
|
92
|
+
*/
|
|
93
|
+
export declare function nearMissHints(page: Page, target: HintTarget): Promise<string[]>;
|
|
94
|
+
/** The block appended under a failure sentence, or `""` when there is
|
|
95
|
+
* nothing to add. */
|
|
96
|
+
export declare function formatHints(lines: string[]): string;
|