@specific.dev/spectest 0.66.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.d.ts +0 -14
- package/dist/components/supabase.js +2 -8
- 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 +2 -20
- 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/src/daemon.ts
CHANGED
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
101
101
|
import {
|
|
102
102
|
InterceptRegistry,
|
|
103
|
+
parseTarget,
|
|
103
104
|
runChain,
|
|
104
105
|
type Interceptor,
|
|
105
106
|
} from "./harness/intercept.js";
|
|
@@ -174,6 +175,7 @@ import {
|
|
|
174
175
|
startRecording,
|
|
175
176
|
stopRecording,
|
|
176
177
|
truncateUtf8,
|
|
178
|
+
type StepBlock,
|
|
177
179
|
type TestEvent,
|
|
178
180
|
type OmittedBody,
|
|
179
181
|
} from "./recorder.js";
|
|
@@ -2796,16 +2798,22 @@ function ingressClaimsHostname(hostname: string): boolean {
|
|
|
2796
2798
|
return false;
|
|
2797
2799
|
}
|
|
2798
2800
|
|
|
2799
|
-
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2801
|
+
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2802
|
+
*
|
|
2803
|
+
* A forced 503 is the step doing exactly what its description says, so it is
|
|
2804
|
+
* **not** marked failed — reddening the outcome the test asked for trains the
|
|
2805
|
+
* reader to ignore the colour. Only a handler that threw is marked, because
|
|
2806
|
+
* that 500 is a bug in the middleware rather than the outage it stands for. */
|
|
2800
2807
|
function recordInterceptedRequest(
|
|
2801
2808
|
it: Interceptor,
|
|
2802
|
-
rec: { method: string; path: string; status: number; answeredBy: string },
|
|
2809
|
+
rec: { method: string; path: string; status: number; answeredBy: string; threw?: boolean },
|
|
2803
2810
|
durationMs: number,
|
|
2804
2811
|
): void {
|
|
2805
2812
|
const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
|
|
2806
2813
|
if (parentSeq === undefined || !isRecording()) return;
|
|
2807
|
-
const by =
|
|
2808
|
-
|
|
2814
|
+
const by = rec.threw
|
|
2815
|
+
? "the interceptor threw — 500 from spectest, not from your handler"
|
|
2816
|
+
: rec.answeredBy === "handler"
|
|
2809
2817
|
? "answered by the interceptor"
|
|
2810
2818
|
: rec.answeredBy === "modified"
|
|
2811
2819
|
? "upstream answer replaced by the interceptor"
|
|
@@ -2814,22 +2822,42 @@ function recordInterceptedRequest(
|
|
|
2814
2822
|
kind: "intercept-request",
|
|
2815
2823
|
parentSeq,
|
|
2816
2824
|
title: `${rec.method} ${rec.path} → ${rec.status}`,
|
|
2817
|
-
status: rec.
|
|
2825
|
+
status: rec.threw ? "failed" : "passed",
|
|
2826
|
+
// Method, path and status are already the title; the one thing the row
|
|
2827
|
+
// cannot say for itself is who produced that status.
|
|
2818
2828
|
blocks: [
|
|
2819
|
-
{
|
|
2820
|
-
type: "kv",
|
|
2821
|
-
rows: [
|
|
2822
|
-
{ label: "Host", value: it.hostname },
|
|
2823
|
-
{ label: "Request", value: `${rec.method} ${rec.path}` },
|
|
2824
|
-
{ label: "Status", value: String(rec.status) },
|
|
2825
|
-
{ label: "Answered", value: by },
|
|
2826
|
-
],
|
|
2827
|
-
},
|
|
2829
|
+
{ type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
|
|
2828
2830
|
],
|
|
2829
2831
|
durationMs,
|
|
2830
2832
|
});
|
|
2831
2833
|
}
|
|
2832
2834
|
|
|
2835
|
+
/** The handler's own source, which is what the interceptor actually does.
|
|
2836
|
+
*
|
|
2837
|
+
* Free and impossible to let rot: Bun runs the project's TypeScript
|
|
2838
|
+
* directly, so `toString()` is the text the author wrote. It is the step's
|
|
2839
|
+
* body, under the description — a mechanism the reader can check against
|
|
2840
|
+
* the intent the description claims. A handler long enough to be a wall of
|
|
2841
|
+
* code folds into a disclosure instead of pushing the requests off screen.
|
|
2842
|
+
*/
|
|
2843
|
+
function handlerBlocks(handler: InterceptHandler): StepBlock[] {
|
|
2844
|
+
let src: string;
|
|
2845
|
+
try {
|
|
2846
|
+
src = String(handler);
|
|
2847
|
+
} catch {
|
|
2848
|
+
return [];
|
|
2849
|
+
}
|
|
2850
|
+
const code: StepBlock = { type: "code", lang: "ts", label: "Handler", code: src };
|
|
2851
|
+
return src.split("\n").length > HANDLER_FOLD_LINES
|
|
2852
|
+
? [{ type: "details", summary: "Handler", blocks: [code] }]
|
|
2853
|
+
: [code];
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
/** Past this many lines the handler source is folded away. Chosen so the
|
|
2857
|
+
* shapes this feature is for — a one-line forced status, a short
|
|
2858
|
+
* fail-twice-then-pass — always show, and only a real program hides. */
|
|
2859
|
+
const HANDLER_FOLD_LINES = 15;
|
|
2860
|
+
|
|
2833
2861
|
/**
|
|
2834
2862
|
* Put middleware in front of a hostname the ingress serves — the
|
|
2835
2863
|
* implementation behind `ctx.intercept`.
|
|
@@ -2838,19 +2866,25 @@ function recordInterceptedRequest(
|
|
|
2838
2866
|
* the daemon (DNS does not point here), so the interceptor could only be
|
|
2839
2867
|
* silent — and silence is the failure mode this whole layer is designed
|
|
2840
2868
|
* against. The message names the two ways to get a route.
|
|
2869
|
+
*
|
|
2870
|
+
* The `description` is required because it is the step's whole title in the
|
|
2871
|
+
* timeline and in the CLI's failure detail: it is what tells a reader why
|
|
2872
|
+
* the UI under test went to its error state. The handler source says how;
|
|
2873
|
+
* only the author can say what it means.
|
|
2841
2874
|
*/
|
|
2842
2875
|
function registerInterceptor(
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2876
|
+
target: string,
|
|
2877
|
+
description: string,
|
|
2878
|
+
handler: InterceptHandler,
|
|
2846
2879
|
): Interception {
|
|
2847
|
-
const
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2880
|
+
const { hostname: host, path } = parseTarget(target);
|
|
2881
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
2882
|
+
throw new Error(
|
|
2883
|
+
`ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
|
|
2884
|
+
`environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`,
|
|
2885
|
+
);
|
|
2851
2886
|
}
|
|
2852
|
-
|
|
2853
|
-
if (!handler) {
|
|
2887
|
+
if (typeof handler !== "function") {
|
|
2854
2888
|
throw new Error("ctx.intercept: a handler (req, next) => Response is required");
|
|
2855
2889
|
}
|
|
2856
2890
|
if (!ingressClaimsHostname(host)) {
|
|
@@ -2863,20 +2897,42 @@ function registerInterceptor(
|
|
|
2863
2897
|
}
|
|
2864
2898
|
const resv = reserveEvent();
|
|
2865
2899
|
const it = INTERCEPTORS.register(host, path, handler);
|
|
2900
|
+
const where = `${host}${it.path === "/" ? "" : it.path}`;
|
|
2866
2901
|
const seq = recordStep(
|
|
2867
2902
|
{
|
|
2868
2903
|
kind: "intercept",
|
|
2869
|
-
|
|
2904
|
+
// The description alone. The pill already says INTERCEPT, and the
|
|
2905
|
+
// target is in the panel — a row that repeats both spends its width
|
|
2906
|
+
// on what the reader can already see and none of it on the one thing
|
|
2907
|
+
// only the author knows.
|
|
2908
|
+
title: description.trim(),
|
|
2870
2909
|
blocks: [
|
|
2910
|
+
...handlerBlocks(handler),
|
|
2871
2911
|
{
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2912
|
+
// The target IS the summary line, so the closed disclosure still
|
|
2913
|
+
// says where the interceptor sits — a generic "Target" label
|
|
2914
|
+
// would spend the one visible line saying nothing.
|
|
2915
|
+
type: "details",
|
|
2916
|
+
summary: where,
|
|
2917
|
+
blocks: [
|
|
2918
|
+
{
|
|
2919
|
+
type: "kv",
|
|
2920
|
+
rows: [
|
|
2921
|
+
{
|
|
2922
|
+
label: "Matches",
|
|
2923
|
+
value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
|
|
2924
|
+
},
|
|
2925
|
+
{
|
|
2926
|
+
label: "Until",
|
|
2927
|
+
value: it.scope === undefined ? "remove()" : "the end of this test",
|
|
2928
|
+
},
|
|
2929
|
+
],
|
|
2930
|
+
},
|
|
2876
2931
|
],
|
|
2877
2932
|
},
|
|
2878
2933
|
],
|
|
2879
|
-
|
|
2934
|
+
// No duration: this is a marker for the moment the interceptor went
|
|
2935
|
+
// up, and "(0 ms)" reads as a step that did nothing.
|
|
2880
2936
|
},
|
|
2881
2937
|
resv,
|
|
2882
2938
|
);
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
InterceptRegistry,
|
|
4
4
|
hostnameMatches,
|
|
5
5
|
normalizeMount,
|
|
6
|
+
parseTarget,
|
|
6
7
|
pathMounts,
|
|
7
8
|
runChain,
|
|
8
9
|
type Interceptor,
|
|
@@ -39,6 +40,41 @@ describe("normalizeMount", () => {
|
|
|
39
40
|
});
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
describe("parseTarget", () => {
|
|
44
|
+
test("a bare hostname mounts at the root", () => {
|
|
45
|
+
expect(parseTarget("app.test")).toEqual({ hostname: "app.test", path: "/" });
|
|
46
|
+
});
|
|
47
|
+
test("a path rides the target", () => {
|
|
48
|
+
expect(parseTarget("app.test/api/sync")).toEqual({
|
|
49
|
+
hostname: "app.test",
|
|
50
|
+
path: "/api/sync",
|
|
51
|
+
});
|
|
52
|
+
expect(parseTarget("app.test/api/")).toEqual({ hostname: "app.test", path: "/api" });
|
|
53
|
+
expect(parseTarget("app.test/")).toEqual({ hostname: "app.test", path: "/" });
|
|
54
|
+
});
|
|
55
|
+
test("a scheme and case are tolerated", () => {
|
|
56
|
+
expect(parseTarget("https://App.Test/API")).toEqual({
|
|
57
|
+
hostname: "app.test",
|
|
58
|
+
path: "/api",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
test("a wildcard host keeps its pattern", () => {
|
|
62
|
+
expect(parseTarget("*.example.com/v1")).toEqual({
|
|
63
|
+
hostname: "*.example.com",
|
|
64
|
+
path: "/v1",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
test("rejects a target that is only a path, or none at all", () => {
|
|
68
|
+
expect(() => parseTarget("/api")).toThrow(/names no hostname/);
|
|
69
|
+
expect(() => parseTarget("")).toThrow(/target hostname is required/);
|
|
70
|
+
expect(() => parseTarget(" ")).toThrow(/target hostname is required/);
|
|
71
|
+
});
|
|
72
|
+
test("rejects a query or fragment", () => {
|
|
73
|
+
expect(() => parseTarget("app.test/api?x=1")).toThrow(/mount prefix/);
|
|
74
|
+
expect(() => parseTarget("app.test?x=1")).toThrow(/query or fragment/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
42
78
|
describe("hostnameMatches", () => {
|
|
43
79
|
test("exact and wildcard", () => {
|
|
44
80
|
expect(hostnameMatches("api.test", "api.test")).toBe(true);
|
package/src/harness/intercept.ts
CHANGED
|
@@ -56,6 +56,12 @@ export interface InterceptedRequest {
|
|
|
56
56
|
* upstream via `next()` untouched (`upstream`), or the upstream's answer
|
|
57
57
|
* replaced by the interceptor after `next()` (`modified`). */
|
|
58
58
|
answeredBy: "handler" | "upstream" | "modified";
|
|
59
|
+
/** The handler threw (or returned something that is not a `Response`), so
|
|
60
|
+
* the 500 below is a bug in the test's own middleware rather than the
|
|
61
|
+
* outage it was asked to produce. The distinction cannot be recovered
|
|
62
|
+
* from the status: a deliberate 500 and a crashed handler are the same
|
|
63
|
+
* number, and only this tells the timeline which one to mark as wrong. */
|
|
64
|
+
threw?: boolean;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
export interface Interceptor {
|
|
@@ -71,6 +77,39 @@ export interface Interceptor {
|
|
|
71
77
|
requests: InterceptedRequest[];
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Split an intercept target — `app.test`, `app.test/api/sync`,
|
|
82
|
+
* `*.example.com/v1` — into the hostname and the mount path.
|
|
83
|
+
*
|
|
84
|
+
* The path rides the target rather than a second argument because that is
|
|
85
|
+
* what it is: part of what the interceptor claims, not a separate knob. It
|
|
86
|
+
* also keeps the signature down to one string, so the description that
|
|
87
|
+
* follows can never be mistaken for a path.
|
|
88
|
+
*
|
|
89
|
+
* A scheme is tolerated (`https://app.test/api`) since that is how the same
|
|
90
|
+
* address is written everywhere else.
|
|
91
|
+
*/
|
|
92
|
+
export function parseTarget(target: string): { hostname: string; path: string } {
|
|
93
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
94
|
+
throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
|
|
95
|
+
}
|
|
96
|
+
const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
97
|
+
const slash = bare.indexOf("/");
|
|
98
|
+
const hostname = slash === -1 ? bare : bare.slice(0, slash);
|
|
99
|
+
const rest = slash === -1 ? undefined : bare.slice(slash);
|
|
100
|
+
if (hostname === "") {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (hostname.includes("?") || hostname.includes("#")) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { hostname, path: normalizeMount(rest) };
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
75
114
|
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
76
115
|
export function pathMounts(mount: string, pathname: string): boolean {
|
|
@@ -222,6 +261,7 @@ export async function runChain(
|
|
|
222
261
|
);
|
|
223
262
|
record.answeredBy = "handler";
|
|
224
263
|
record.status = 500;
|
|
264
|
+
record.threw = true;
|
|
225
265
|
it.calls++;
|
|
226
266
|
it.requests.push(record);
|
|
227
267
|
observe?.(it, record, Date.now() - started);
|
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 {
|