@specific.dev/spectest 0.66.0 → 0.68.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.d.ts +0 -14
- package/dist/components/supabase.js +2 -8
- package/dist/daemon.js +155 -35
- package/dist/harness/intercept.d.ts +22 -0
- package/dist/harness/intercept.js +29 -0
- package/dist/harness/wrapper-rules.d.ts +149 -0
- package/dist/harness/wrapper-rules.js +422 -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 +2 -20
- package/src/daemon.ts +171 -34
- package/src/harness/intercept.test.ts +36 -0
- package/src/harness/intercept.ts +40 -0
- package/src/harness/wrapper-rules.test.ts +170 -0
- package/src/harness/wrapper-rules.ts +547 -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/src/index.ts
CHANGED
|
@@ -116,7 +116,20 @@ import {
|
|
|
116
116
|
getBrowserProbe,
|
|
117
117
|
DEFAULT_ACTION_TIMEOUT_MS,
|
|
118
118
|
} from "./locator.js";
|
|
119
|
-
import {
|
|
119
|
+
import {
|
|
120
|
+
classifyLocatorFailure,
|
|
121
|
+
formatWaited,
|
|
122
|
+
locatorFailureMessage,
|
|
123
|
+
} from "./locator-errors.js";
|
|
124
|
+
import {
|
|
125
|
+
containsText,
|
|
126
|
+
containsTextArray,
|
|
127
|
+
escapeInvisible,
|
|
128
|
+
matchesText,
|
|
129
|
+
matchesTextArray,
|
|
130
|
+
textDifferenceNote,
|
|
131
|
+
type TextMatchOptions,
|
|
132
|
+
} from "./text-match.js";
|
|
120
133
|
|
|
121
134
|
export type { UrlPattern } from "./url-match.js";
|
|
122
135
|
import type { UrlPattern } from "./url-match.js";
|
|
@@ -182,7 +195,8 @@ export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
|
182
195
|
*/
|
|
183
196
|
export interface Interception {
|
|
184
197
|
hostname: string;
|
|
185
|
-
/** The normalised mount path (`"/"` when
|
|
198
|
+
/** The normalised mount path parsed off the target (`"/"` when the
|
|
199
|
+
* target was a bare hostname). */
|
|
186
200
|
path: string;
|
|
187
201
|
/** How many requests the interceptor has seen. Provenance-wrapped, so an
|
|
188
202
|
* `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
|
|
@@ -545,12 +559,23 @@ export interface SpectestContext<
|
|
|
545
559
|
* make your **own** backend misbehave for one test: force a 500 from an
|
|
546
560
|
* API route, add latency, fail twice then pass, or just count calls.
|
|
547
561
|
*
|
|
562
|
+
* `target` is the hostname, optionally with a mount path:
|
|
563
|
+
* `"app.test"` claims every request to that host, `"app.test/api/sync"`
|
|
564
|
+
* claims that path and everything below it (like `app.use(path, fn)`).
|
|
565
|
+
*
|
|
566
|
+
* `description` is what the environment now **does**, in the present
|
|
567
|
+
* tense — it is the step's whole title in the timeline and in the CLI's
|
|
568
|
+
* failure detail, so it is what tells a reader why the page under test
|
|
569
|
+
* went to its error state. Write the effect, not the act of intercepting
|
|
570
|
+
* (the step is already labelled INTERCEPT) and not the test's goal:
|
|
571
|
+
* `"the sync endpoint returns 503"`, not `"intercept sync"`, `"mock the
|
|
572
|
+
* API"` or `"test error handling"`. The handler's own source is shown
|
|
573
|
+
* under it, so the description carries the intent, never the mechanism.
|
|
574
|
+
*
|
|
548
575
|
* Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
|
|
549
576
|
* response (a proxied service, or a fake). Return a `Response` to answer
|
|
550
577
|
* yourself, `next()` to pass through, or change what `next()` returned.
|
|
551
|
-
*
|
|
552
|
-
* (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
|
|
553
|
-
* registration order.
|
|
578
|
+
* Interceptors run in registration order.
|
|
554
579
|
*
|
|
555
580
|
* Only traffic that reaches the daemon can be intercepted: the browser,
|
|
556
581
|
* `ctx.fetch`, and any container that calls the hostname — so the service
|
|
@@ -564,16 +589,18 @@ export interface SpectestContext<
|
|
|
564
589
|
* `remove()`. Every request it sees is recorded under the intercept step.
|
|
565
590
|
*
|
|
566
591
|
* ```ts
|
|
567
|
-
* const outage = ctx.intercept(
|
|
568
|
-
*
|
|
592
|
+
* const outage = ctx.intercept(
|
|
593
|
+
* "api.test/functions/v1/sync",
|
|
594
|
+
* "the sync function returns 500",
|
|
595
|
+
* () => new Response("boom", { status: 500 }),
|
|
596
|
+
* );
|
|
569
597
|
* await page.getByRole("button", { name: "Sync" }).click();
|
|
570
598
|
* await expect(page.getByText("Retry")).toBeVisible();
|
|
571
599
|
* expect(outage.calls).toBe(1);
|
|
572
600
|
* outage.remove(); // the retry now reaches the real function
|
|
573
601
|
* ```
|
|
574
602
|
*/
|
|
575
|
-
intercept(
|
|
576
|
-
intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
|
|
603
|
+
intercept(target: string, description: string, handler: InterceptHandler): Interception;
|
|
577
604
|
/**
|
|
578
605
|
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
579
606
|
*
|
|
@@ -2657,6 +2684,15 @@ export interface Expectation extends Matchers {
|
|
|
2657
2684
|
not: Matchers;
|
|
2658
2685
|
}
|
|
2659
2686
|
|
|
2687
|
+
/** Options for the text matchers — playwright's set, same names, same
|
|
2688
|
+
* meanings. */
|
|
2689
|
+
export interface TextMatcherOptions extends TextMatchOptions {
|
|
2690
|
+
timeout?: number;
|
|
2691
|
+
/** Read `innerText` (what the page renders — hidden elements dropped,
|
|
2692
|
+
* `text-transform` applied) instead of `textContent`. */
|
|
2693
|
+
useInnerText?: boolean;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2660
2696
|
/**
|
|
2661
2697
|
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
2662
2698
|
* `expect(locator)` matchers. Each polls the element until it passes or a
|
|
@@ -2668,10 +2704,33 @@ export interface LocatorMatchers {
|
|
|
2668
2704
|
toBeVisible(opts?: { timeout?: number }): Promise<void>;
|
|
2669
2705
|
/** The element is absent or hidden. */
|
|
2670
2706
|
toBeHidden(opts?: { timeout?: number }): Promise<void>;
|
|
2671
|
-
/**
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2707
|
+
/**
|
|
2708
|
+
* The element's text equals `expected`, or matches it when it is a RegExp.
|
|
2709
|
+
*
|
|
2710
|
+
* **Whitespace is normalized on both sides**, exactly as playwright does
|
|
2711
|
+
* it: the text is trimmed and every run of whitespace becomes one space.
|
|
2712
|
+
* So a typed space matches the no-break space (U+00A0) that
|
|
2713
|
+
* `Intl.NumberFormat` puts between thousands, and a value wrapped across
|
|
2714
|
+
* two lines in the markup matches the one-line string you wrote.
|
|
2715
|
+
*
|
|
2716
|
+
* Pass an **array** to assert over every element the locator matches, in
|
|
2717
|
+
* order; the counts must then agree.
|
|
2718
|
+
*/
|
|
2719
|
+
toHaveText(
|
|
2720
|
+
expected: string | RegExp | Array<string | RegExp>,
|
|
2721
|
+
opts?: TextMatcherOptions,
|
|
2722
|
+
): Promise<void>;
|
|
2723
|
+
/**
|
|
2724
|
+
* The element's text contains `expected` — a substring, or a RegExp tested
|
|
2725
|
+
* against the text. Whitespace is normalized as in {@link toHaveText}.
|
|
2726
|
+
*
|
|
2727
|
+
* An **array** asserts that the matched elements contain these texts in
|
|
2728
|
+
* order; unlike `toHaveText` extra elements between them are allowed.
|
|
2729
|
+
*/
|
|
2730
|
+
toContainText(
|
|
2731
|
+
expected: string | RegExp | Array<string | RegExp>,
|
|
2732
|
+
opts?: TextMatcherOptions,
|
|
2733
|
+
): Promise<void>;
|
|
2675
2734
|
/** The input's value equals `expected` (or matches a RegExp). */
|
|
2676
2735
|
toHaveValue(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
|
|
2677
2736
|
/** The locator resolves to exactly `expected` elements. */
|
|
@@ -2805,12 +2864,32 @@ function buildLocatorMatchers(
|
|
|
2805
2864
|
// matches a hidden element fail identically — so the count is read once,
|
|
2806
2865
|
// at failure time only. `toHaveCount` is left alone: "expected count 2,
|
|
2807
2866
|
// got 0" already says it, and better.
|
|
2867
|
+
//
|
|
2868
|
+
// Either way the message ends with the near-miss block — what the page
|
|
2869
|
+
// holds that is close to what was asked for (see locator-hints.ts) — so
|
|
2870
|
+
// the author does not have to run again to find out.
|
|
2871
|
+
const nearMiss = async (): Promise<string> => {
|
|
2872
|
+
try {
|
|
2873
|
+
return await probe.nearMiss();
|
|
2874
|
+
} catch {
|
|
2875
|
+
return "";
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2808
2878
|
const elementFailure = async (err: unknown): Promise<string | undefined> => {
|
|
2809
|
-
if (err !== undefined)
|
|
2879
|
+
if (err !== undefined) {
|
|
2880
|
+
const failure = classifyLocatorFailure(probe.label, err);
|
|
2881
|
+
if (!failure) return undefined;
|
|
2882
|
+
return failure.kind === "no-match"
|
|
2883
|
+
? failure.message + (await nearMiss())
|
|
2884
|
+
: failure.message;
|
|
2885
|
+
}
|
|
2810
2886
|
if (expectsGone || matcher === "toHaveCount") return undefined;
|
|
2811
2887
|
try {
|
|
2812
2888
|
if ((await probe.count()) === 0) {
|
|
2813
|
-
return
|
|
2889
|
+
return (
|
|
2890
|
+
`No element matches ${probe.label} (waited ${formatWaited(budget)})` +
|
|
2891
|
+
(await nearMiss())
|
|
2892
|
+
);
|
|
2814
2893
|
}
|
|
2815
2894
|
} catch {
|
|
2816
2895
|
/* The page is gone or the chain is invalid — the matcher's own
|
|
@@ -2882,8 +2961,29 @@ function buildLocatorMatchers(
|
|
|
2882
2961
|
};
|
|
2883
2962
|
|
|
2884
2963
|
const not = negated ? " not" : "";
|
|
2885
|
-
|
|
2886
|
-
|
|
2964
|
+
// The element's text, read the way the caller asked for it. `useInnerText`
|
|
2965
|
+
// is playwright's option and means the same here: what the page renders,
|
|
2966
|
+
// rather than every character in the subtree.
|
|
2967
|
+
const readText = async (opts?: TextMatcherOptions): Promise<string> =>
|
|
2968
|
+
opts?.useInnerText
|
|
2969
|
+
? await probe.innerText(opts.timeout)
|
|
2970
|
+
: ((await probe.textContent(opts?.timeout)) ?? "");
|
|
2971
|
+
const readTexts = async (opts?: TextMatcherOptions): Promise<string[]> =>
|
|
2972
|
+
opts?.useInnerText ? await probe.allInnerTexts() : await probe.allTextContents();
|
|
2973
|
+
const isArrayExpectation = (
|
|
2974
|
+
v: string | RegExp | Array<string | RegExp>,
|
|
2975
|
+
): v is Array<string | RegExp> => Array.isArray(v);
|
|
2976
|
+
/** Expected values render as themselves; a RegExp renders as its source. */
|
|
2977
|
+
const fmtExpected = (v: string | RegExp | Array<string | RegExp>): string =>
|
|
2978
|
+
v instanceof RegExp
|
|
2979
|
+
? String(v)
|
|
2980
|
+
: Array.isArray(v)
|
|
2981
|
+
? `[${v.map(fmtExpected).join(", ")}]`
|
|
2982
|
+
: fmt(v);
|
|
2983
|
+
/** What goes on the assertion event: a RegExp cannot be serialized. */
|
|
2984
|
+
const expectedValue = (
|
|
2985
|
+
v: string | RegExp | Array<string | RegExp>,
|
|
2986
|
+
): unknown => (v instanceof RegExp ? String(v) : Array.isArray(v) ? v.map(expectedValue) : v);
|
|
2887
2987
|
|
|
2888
2988
|
return {
|
|
2889
2989
|
toBeVisible: (opts) =>
|
|
@@ -2911,33 +3011,53 @@ function buildLocatorMatchers(
|
|
|
2911
3011
|
"toHaveText",
|
|
2912
3012
|
opts?.timeout,
|
|
2913
3013
|
async () => {
|
|
2914
|
-
|
|
2915
|
-
|
|
3014
|
+
if (isArrayExpectation(expected)) {
|
|
3015
|
+
const texts = await readTexts(opts);
|
|
3016
|
+
return { satisfied: matchesTextArray(texts, expected, opts), actual: texts };
|
|
3017
|
+
}
|
|
3018
|
+
const t = await readText(opts);
|
|
3019
|
+
return { satisfied: matchesText(t, expected, opts), actual: t };
|
|
2916
3020
|
},
|
|
2917
|
-
(a) =>
|
|
2918
|
-
|
|
3021
|
+
(a) =>
|
|
3022
|
+
`expected ${probe.label}${not} to have text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
3023
|
+
textDifferenceNote(a, expected, "equal"),
|
|
3024
|
+
expectedValue(expected),
|
|
2919
3025
|
),
|
|
2920
3026
|
toContainText: (expected, opts) =>
|
|
2921
3027
|
run(
|
|
2922
3028
|
"toContainText",
|
|
2923
3029
|
opts?.timeout,
|
|
2924
3030
|
async () => {
|
|
2925
|
-
|
|
2926
|
-
|
|
3031
|
+
if (isArrayExpectation(expected)) {
|
|
3032
|
+
const texts = await readTexts(opts);
|
|
3033
|
+
return { satisfied: containsTextArray(texts, expected, opts), actual: texts };
|
|
3034
|
+
}
|
|
3035
|
+
const t = await readText(opts);
|
|
3036
|
+
return { satisfied: containsText(t, expected, opts), actual: t };
|
|
2927
3037
|
},
|
|
2928
|
-
(a) =>
|
|
2929
|
-
|
|
3038
|
+
(a) =>
|
|
3039
|
+
`expected ${probe.label}${not} to contain text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
3040
|
+
textDifferenceNote(a, expected, "contains"),
|
|
3041
|
+
expectedValue(expected),
|
|
2930
3042
|
),
|
|
3043
|
+
// Deliberately NOT whitespace-normalized: an input's value is data the
|
|
3044
|
+
// user typed or the app set, not rendered text, and playwright compares
|
|
3045
|
+
// it exactly for the same reason. When that exactness is what failed,
|
|
3046
|
+
// `textDifferenceNote` says so instead of leaving two identical-looking
|
|
3047
|
+
// strings on screen.
|
|
2931
3048
|
toHaveValue: (expected, opts) =>
|
|
2932
3049
|
run(
|
|
2933
3050
|
"toHaveValue",
|
|
2934
3051
|
opts?.timeout,
|
|
2935
3052
|
async () => {
|
|
2936
3053
|
const v = await probe.inputValue(opts?.timeout);
|
|
2937
|
-
|
|
3054
|
+
const satisfied = expected instanceof RegExp ? expected.test(v) : v === expected;
|
|
3055
|
+
return { satisfied, actual: v };
|
|
2938
3056
|
},
|
|
2939
|
-
(a) =>
|
|
2940
|
-
|
|
3057
|
+
(a) =>
|
|
3058
|
+
`expected ${probe.label}${not} to have value ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
3059
|
+
textDifferenceNote(a, expected, "equal"),
|
|
3060
|
+
expectedValue(expected),
|
|
2941
3061
|
),
|
|
2942
3062
|
toHaveCount: (expected, opts) =>
|
|
2943
3063
|
run(
|
|
@@ -3162,7 +3282,8 @@ function buildCore(
|
|
|
3162
3282
|
run(
|
|
3163
3283
|
"toBe",
|
|
3164
3284
|
Object.is(actual, exp),
|
|
3165
|
-
`expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}
|
|
3285
|
+
`expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}` +
|
|
3286
|
+
textDifferenceNote(actual, exp, "equal"),
|
|
3166
3287
|
exp,
|
|
3167
3288
|
);
|
|
3168
3289
|
},
|
|
@@ -3177,7 +3298,8 @@ function buildCore(
|
|
|
3177
3298
|
run(
|
|
3178
3299
|
"toEqual",
|
|
3179
3300
|
equal,
|
|
3180
|
-
`expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}
|
|
3301
|
+
`expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}` +
|
|
3302
|
+
textDifferenceNote(actual, exp, "equal"),
|
|
3181
3303
|
exp,
|
|
3182
3304
|
);
|
|
3183
3305
|
},
|
|
@@ -3245,7 +3367,8 @@ function buildCore(
|
|
|
3245
3367
|
run(
|
|
3246
3368
|
"toContain",
|
|
3247
3369
|
contained,
|
|
3248
|
-
`expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}
|
|
3370
|
+
`expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}` +
|
|
3371
|
+
textDifferenceNote(actual, exp, "contains"),
|
|
3249
3372
|
exp,
|
|
3250
3373
|
);
|
|
3251
3374
|
},
|
|
@@ -3295,7 +3418,11 @@ function buildCore(
|
|
|
3295
3418
|
}
|
|
3296
3419
|
|
|
3297
3420
|
function fmt(v: unknown): string {
|
|
3298
|
-
|
|
3421
|
+
// A string is escaped down to its invisible characters, so two values that
|
|
3422
|
+
// print the same on screen do not print the same in a failure. Without it a
|
|
3423
|
+
// no-break space and a space are the same three characters wide, and the
|
|
3424
|
+
// message reads "expected "15 000 kr", got "15 000 kr"".
|
|
3425
|
+
if (typeof v === "string") return escapeInvisible(JSON.stringify(v));
|
|
3299
3426
|
if (typeof v === "bigint") return `${v}n`;
|
|
3300
3427
|
if (v === undefined) return "undefined";
|
|
3301
3428
|
try {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
classifyLocatorFailure,
|
|
5
|
+
formatWaited,
|
|
6
|
+
locatorFailureMessage,
|
|
7
|
+
rewriteLocatorErrorWithHints,
|
|
8
|
+
} from "./locator-errors.js";
|
|
4
9
|
|
|
5
10
|
/** A playwright actionability timeout, shaped exactly as playwright-core
|
|
6
11
|
* 1.61 raises one: `name`, the message with the call log appended, the same
|
|
@@ -83,22 +88,105 @@ describe("locatorFailureMessage", () => {
|
|
|
83
88
|
);
|
|
84
89
|
});
|
|
85
90
|
|
|
86
|
-
test("leaves errors that are
|
|
87
|
-
const strict = new Error(
|
|
88
|
-
"click: Error: strict mode violation: locator('p') resolved to 2 elements",
|
|
89
|
-
);
|
|
90
|
-
expect(locatorFailureMessage('css "p"', strict)).toBeUndefined();
|
|
91
|
+
test("leaves errors that are neither a timeout nor a strict violation alone", () => {
|
|
91
92
|
expect(locatorFailureMessage('css "p"', new Error("page closed"))).toBeUndefined();
|
|
92
93
|
expect(locatorFailureMessage('css "p"', "not an error at all")).toBeUndefined();
|
|
93
94
|
});
|
|
95
|
+
|
|
96
|
+
test("an ambiguous locator says how to narrow it, and lists the matches", () => {
|
|
97
|
+
// Playwright's own shape: the count, then one numbered line per match.
|
|
98
|
+
const strict = new Error(
|
|
99
|
+
"click: Error: strict mode violation: getByRole('button') resolved to 2 elements:\n" +
|
|
100
|
+
" 1) <button>Save</button> aka getByRole('button', { name: 'Save' })\n" +
|
|
101
|
+
" 2) <button>Save all</button> aka getByRole('button', { name: 'Save all' })\n",
|
|
102
|
+
);
|
|
103
|
+
expect(locatorFailureMessage("role button", strict)).toBe(
|
|
104
|
+
"role button matches 2 elements, so it is ambiguous — narrow it with .first(), " +
|
|
105
|
+
".nth(i), .filter({ hasText }) or a more specific query:\n" +
|
|
106
|
+
" - <button>Save</button> aka getByRole('button', { name: 'Save' })\n" +
|
|
107
|
+
" - <button>Save all</button> aka getByRole('button', { name: 'Save all' })",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an ambiguous locator with no listed matches still says what to do", () => {
|
|
112
|
+
const strict = new Error("strict mode violation: locator('p') resolved to 7 elements");
|
|
113
|
+
expect(locatorFailureMessage('css "p"', strict)).toBe(
|
|
114
|
+
'css "p" matches 7 elements, so it is ambiguous — narrow it with .first(), ' +
|
|
115
|
+
".nth(i), .filter({ hasText }) or a more specific query",
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("classifyLocatorFailure", () => {
|
|
121
|
+
test("only a locator that matched nothing is worth asking the page about", () => {
|
|
122
|
+
const missing = timeoutError("click: Timeout 5000ms exceeded.", [
|
|
123
|
+
" - waiting for getByTestId('add')",
|
|
124
|
+
]);
|
|
125
|
+
expect(classifyLocatorFailure('testid "add"', missing)?.kind).toBe("no-match");
|
|
126
|
+
|
|
127
|
+
const disabled = timeoutError("click: Timeout 5000ms exceeded.", [
|
|
128
|
+
" - waiting for locator('#dis')",
|
|
129
|
+
' - locator resolved to <button id="dis" disabled>Nope</button>',
|
|
130
|
+
" - element is not enabled",
|
|
131
|
+
]);
|
|
132
|
+
expect(classifyLocatorFailure('css "#dis"', disabled)?.kind).toBe("state");
|
|
133
|
+
|
|
134
|
+
const strict = new Error("strict mode violation: locator('p') resolved to 2 elements");
|
|
135
|
+
expect(classifyLocatorFailure('css "p"', strict)?.kind).toBe("strict");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("rewriteLocatorErrorWithHints", () => {
|
|
140
|
+
const missing = (): Error =>
|
|
141
|
+
timeoutError("click: Timeout 5000ms exceeded.", [" - waiting for getByTestId('add')"]);
|
|
142
|
+
|
|
143
|
+
test("appends the near-miss block to a locator that matched nothing", async () => {
|
|
144
|
+
const err = (await rewriteLocatorErrorWithHints(missing(), 'testid "add"', async () =>
|
|
145
|
+
"\nClose matches on the page:\n - testid \"add-todo\"",
|
|
146
|
+
)) as Error;
|
|
147
|
+
expect(err.message).toBe(
|
|
148
|
+
'No element matches testid "add" (waited 5s)\n' +
|
|
149
|
+
'Close matches on the page:\n - testid "add-todo"',
|
|
150
|
+
);
|
|
151
|
+
// The stack's copy of the message is rewritten too, or the CLI would
|
|
152
|
+
// print the friendly sentence and the raw timeout under it.
|
|
153
|
+
expect(err.stack).toContain('Close matches on the page');
|
|
154
|
+
expect(err.stack).not.toContain("Timeout 5000ms exceeded");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("never asks the page about a failure it could not explain", async () => {
|
|
158
|
+
const disabled = timeoutError("click: Timeout 5000ms exceeded.", [
|
|
159
|
+
" - waiting for locator('#dis')",
|
|
160
|
+
' - locator resolved to <button id="dis" disabled>Nope</button>',
|
|
161
|
+
" - element is not enabled",
|
|
162
|
+
]);
|
|
163
|
+
let asked = false;
|
|
164
|
+
const err = (await rewriteLocatorErrorWithHints(disabled, 'css "#dis"', async () => {
|
|
165
|
+
asked = true;
|
|
166
|
+
return "should not appear";
|
|
167
|
+
})) as Error;
|
|
168
|
+
expect(asked).toBe(false);
|
|
169
|
+
expect(err.message).toBe('Element css "#dis" is not enabled (waited 5s)');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a diagnostic that throws leaves the failure standing", async () => {
|
|
173
|
+
const err = (await rewriteLocatorErrorWithHints(missing(), 'testid "add"', async () => {
|
|
174
|
+
throw new Error("page closed");
|
|
175
|
+
})) as Error;
|
|
176
|
+
expect(err.message).toBe('No element matches testid "add" (waited 5s)');
|
|
177
|
+
});
|
|
94
178
|
});
|
|
95
179
|
|
|
96
|
-
describe("
|
|
97
|
-
test("rewrites the message AND the copy embedded in the stack", () => {
|
|
180
|
+
describe("rewriteLocatorErrorWithHints (stack)", () => {
|
|
181
|
+
test("rewrites the message AND the copy embedded in the stack", async () => {
|
|
98
182
|
const err = timeoutError("click: Timeout 5000ms exceeded.", [
|
|
99
183
|
" - waiting for getByTestId('add')",
|
|
100
184
|
]);
|
|
101
|
-
const out =
|
|
185
|
+
const out = (await rewriteLocatorErrorWithHints(
|
|
186
|
+
err,
|
|
187
|
+
'testid "add"',
|
|
188
|
+
async () => "",
|
|
189
|
+
)) as Error;
|
|
102
190
|
|
|
103
191
|
expect(out).toBe(err); // same error: the author's own frames survive
|
|
104
192
|
expect(out.message).toBe('No element matches testid "add" (waited 5s)');
|
|
@@ -107,9 +195,9 @@ describe("rewriteLocatorError", () => {
|
|
|
107
195
|
expect(out.stack).toContain("todo.ts:12:34");
|
|
108
196
|
});
|
|
109
197
|
|
|
110
|
-
test("hands back anything it does not understand untouched", () => {
|
|
198
|
+
test("hands back anything it does not understand untouched", async () => {
|
|
111
199
|
const err = new Error("page closed");
|
|
112
|
-
expect(
|
|
200
|
+
expect(await rewriteLocatorErrorWithHints(err, 'css "p"', async () => "")).toBe(err);
|
|
113
201
|
expect(err.message).toBe("page closed");
|
|
114
202
|
});
|
|
115
203
|
});
|
package/src/locator-errors.ts
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
|
|
@@ -92,38 +97,87 @@ function actionabilityReason(log: string[]): string | undefined {
|
|
|
92
97
|
return undefined;
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
/** How a locator failed. `no-match` is the only kind worth asking the page
|
|
101
|
+
* about (see locator-hints.ts) — for every other kind the element was found
|
|
102
|
+
* and near misses would be noise. */
|
|
103
|
+
export type LocatorFailureKind = "no-match" | "state" | "strict";
|
|
104
|
+
|
|
105
|
+
export interface LocatorFailure {
|
|
106
|
+
message: string;
|
|
107
|
+
kind: LocatorFailureKind;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Playwright's strict-mode violation: the locator matched more than one
|
|
111
|
+
* element and refused to act. It is an ordinary `Error`, not a
|
|
112
|
+
* `TimeoutError`, and its message already lists the matches — which is the
|
|
113
|
+
* useful half, so it is kept and only the wall of text around it is cut. */
|
|
114
|
+
function parseStrictViolation(err: unknown, label: string): LocatorFailure | undefined {
|
|
115
|
+
const e = err as { message?: unknown } | null;
|
|
116
|
+
if (!e || typeof e.message !== "string") return undefined;
|
|
117
|
+
if (!e.message.includes("strict mode violation")) return undefined;
|
|
118
|
+
const m = /resolved to (\d+) elements/.exec(e.message);
|
|
119
|
+
if (!m) return undefined;
|
|
120
|
+
const matches = e.message
|
|
121
|
+
.split("\n")
|
|
122
|
+
.filter((l) => /^\s*\d+\)\s/.test(l))
|
|
123
|
+
.slice(0, 3)
|
|
124
|
+
.map((l) => ` - ${l.trim().replace(/^\d+\)\s*/, "")}`);
|
|
125
|
+
const head =
|
|
126
|
+
`${label} matches ${m[1]} elements, so it is ambiguous` +
|
|
127
|
+
" — narrow it with .first(), .nth(i), .filter({ hasText }) or a more specific query";
|
|
128
|
+
return {
|
|
129
|
+
kind: "strict",
|
|
130
|
+
message: matches.length ? `${head}:\n${matches.join("\n")}` : head,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
95
134
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
135
|
+
* What to fail with, or `undefined` when `err` is not a locator failure we
|
|
136
|
+
* understand (in which case the caller must leave it alone).
|
|
98
137
|
*
|
|
99
138
|
* `label` is the locator's human chain label, the same string the timeline
|
|
100
139
|
* step shows.
|
|
101
140
|
*/
|
|
102
|
-
export function
|
|
141
|
+
export function classifyLocatorFailure(
|
|
142
|
+
label: string,
|
|
143
|
+
err: unknown,
|
|
144
|
+
): LocatorFailure | undefined {
|
|
145
|
+
const strict = parseStrictViolation(err, label);
|
|
146
|
+
if (strict) return strict;
|
|
103
147
|
const detail = parseTimeout(err);
|
|
104
148
|
if (!detail) return undefined;
|
|
105
149
|
const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
|
|
106
|
-
if (!resolved(detail.log))
|
|
150
|
+
if (!resolved(detail.log)) {
|
|
151
|
+
return { kind: "no-match", message: `No element matches ${label} ${waited}` };
|
|
152
|
+
}
|
|
107
153
|
|
|
108
154
|
// It matched, so the failure is about the element's state.
|
|
155
|
+
const state = (message: string): LocatorFailure => ({ kind: "state", message });
|
|
109
156
|
switch (waitedForState(detail.log)) {
|
|
110
157
|
case "hidden":
|
|
111
|
-
return `Element ${label} is still visible ${waited}
|
|
158
|
+
return state(`Element ${label} is still visible ${waited}`);
|
|
112
159
|
case "detached":
|
|
113
|
-
return `Element ${label} is still attached to the page ${waited}
|
|
160
|
+
return state(`Element ${label} is still attached to the page ${waited}`);
|
|
114
161
|
case "visible":
|
|
115
|
-
return `Element ${label} is not visible ${waited}
|
|
162
|
+
return state(`Element ${label} is not visible ${waited}`);
|
|
116
163
|
default:
|
|
117
164
|
break;
|
|
118
165
|
}
|
|
119
166
|
const reason = actionabilityReason(detail.log);
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
return state(
|
|
168
|
+
reason
|
|
169
|
+
? `Element ${label} ${reason} ${waited}`
|
|
170
|
+
: `Element ${label} never became ready for this action ${waited}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The sentence alone. See {@link classifyLocatorFailure}. */
|
|
175
|
+
export function locatorFailureMessage(label: string, err: unknown): string | undefined {
|
|
176
|
+
return classifyLocatorFailure(label, err)?.message;
|
|
123
177
|
}
|
|
124
178
|
|
|
125
179
|
/**
|
|
126
|
-
* Replace a locator
|
|
180
|
+
* Replace a locator failure's message in place and hand the error back, so the
|
|
127
181
|
* caller can `throw` it unchanged in every other respect.
|
|
128
182
|
*
|
|
129
183
|
* The error is mutated rather than wrapped: its stack holds the frames of the
|
|
@@ -132,10 +186,8 @@ export function locatorFailureMessage(label: string, err: unknown): string | und
|
|
|
132
186
|
* too — otherwise the CLI's failure block would print the friendly message and
|
|
133
187
|
* then the timeout wall right under it.
|
|
134
188
|
*/
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (message === undefined) return err;
|
|
138
|
-
const e = err as Error;
|
|
189
|
+
/** Put `message` on `err`, in the message and in the stack's copy of it. */
|
|
190
|
+
function setErrorMessage(e: Error, message: string): Error {
|
|
139
191
|
const old = e.message;
|
|
140
192
|
if (typeof e.stack === "string") {
|
|
141
193
|
for (const [header, replacement] of [
|
|
@@ -151,3 +203,30 @@ export function rewriteLocatorError(err: unknown, label: string): unknown {
|
|
|
151
203
|
e.message = message;
|
|
152
204
|
return e;
|
|
153
205
|
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The rewrite above, plus the near-miss block for the one failure that can
|
|
209
|
+
* carry one — a locator that matched nothing.
|
|
210
|
+
*
|
|
211
|
+
* `hints` is only called in that case, so a page query is never made for a
|
|
212
|
+
* failure it could not explain (a disabled element, a covered one, an
|
|
213
|
+
* ambiguous match). It is expected to be best-effort itself; anything it
|
|
214
|
+
* throws leaves the plain sentence standing.
|
|
215
|
+
*/
|
|
216
|
+
export async function rewriteLocatorErrorWithHints(
|
|
217
|
+
err: unknown,
|
|
218
|
+
label: string,
|
|
219
|
+
hints: () => Promise<string>,
|
|
220
|
+
): Promise<unknown> {
|
|
221
|
+
const failure = classifyLocatorFailure(label, err);
|
|
222
|
+
if (!failure) return err;
|
|
223
|
+
let message = failure.message;
|
|
224
|
+
if (failure.kind === "no-match") {
|
|
225
|
+
try {
|
|
226
|
+
message += await hints();
|
|
227
|
+
} catch {
|
|
228
|
+
/* A diagnostic must never replace the failure it explains. */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return setErrorMessage(err as Error, message);
|
|
232
|
+
}
|