@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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Text comparison for the web-first matchers, and readable notes for the
|
|
2
|
+
// failures that invisible characters cause.
|
|
3
|
+
//
|
|
4
|
+
// Two rules, and they are deliberately different:
|
|
5
|
+
//
|
|
6
|
+
// * **Matching is Playwright's.** `expect(locator).toHaveText(...)` and
|
|
7
|
+
// `toContainText(...)` normalize whitespace on BOTH sides exactly as
|
|
8
|
+
// playwright's own text engine does (`normalizeWhiteSpace` in
|
|
9
|
+
// playwright-core). Without this the SDK disagreed with itself: the same
|
|
10
|
+
// string matched through `getByText("15 000 kr")` (playwright's engine,
|
|
11
|
+
// which normalizes) and failed through `toContainText("15 000 kr")`
|
|
12
|
+
// (ours, which did a raw `includes`). The case that reported it is
|
|
13
|
+
// Swedish number formatting — `Intl.NumberFormat("sv-SE")` groups
|
|
14
|
+
// thousands with U+00A0 NO-BREAK SPACE, and JavaScript's `\s` covers
|
|
15
|
+
// U+00A0, so normalization makes a typed space match it.
|
|
16
|
+
//
|
|
17
|
+
// * **Diagnostics are wider.** A value the author compares themselves
|
|
18
|
+
// (`expect(body).toContain(...)`, `toHaveValue`) is still exact, as it
|
|
19
|
+
// must be — so when such a comparison fails on characters that print the
|
|
20
|
+
// same, the message has to say so. `textDifferenceNote` reports the
|
|
21
|
+
// invisible or look-alike characters that are the whole difference, and
|
|
22
|
+
// `escapeInvisible` makes the two printed strings differ on screen.
|
|
23
|
+
|
|
24
|
+
/** Characters that are invisible on screen (or print as an ordinary space)
|
|
25
|
+
* and their Unicode names, for a message a reader can act on. */
|
|
26
|
+
const INVISIBLE_NAMES: Record<number, string> = {
|
|
27
|
+
0x0009: "TAB",
|
|
28
|
+
0x000a: "LINE FEED",
|
|
29
|
+
0x000b: "LINE TABULATION",
|
|
30
|
+
0x000c: "FORM FEED",
|
|
31
|
+
0x000d: "CARRIAGE RETURN",
|
|
32
|
+
0x00a0: "NO-BREAK SPACE",
|
|
33
|
+
0x00ad: "SOFT HYPHEN",
|
|
34
|
+
0x1680: "OGHAM SPACE MARK",
|
|
35
|
+
0x2000: "EN QUAD",
|
|
36
|
+
0x2001: "EM QUAD",
|
|
37
|
+
0x2002: "EN SPACE",
|
|
38
|
+
0x2003: "EM SPACE",
|
|
39
|
+
0x2004: "THREE-PER-EM SPACE",
|
|
40
|
+
0x2005: "FOUR-PER-EM SPACE",
|
|
41
|
+
0x2006: "SIX-PER-EM SPACE",
|
|
42
|
+
0x2007: "FIGURE SPACE",
|
|
43
|
+
0x2008: "PUNCTUATION SPACE",
|
|
44
|
+
0x2009: "THIN SPACE",
|
|
45
|
+
0x200a: "HAIR SPACE",
|
|
46
|
+
0x200b: "ZERO WIDTH SPACE",
|
|
47
|
+
0x200c: "ZERO WIDTH NON-JOINER",
|
|
48
|
+
0x200d: "ZERO WIDTH JOINER",
|
|
49
|
+
0x200e: "LEFT-TO-RIGHT MARK",
|
|
50
|
+
0x200f: "RIGHT-TO-LEFT MARK",
|
|
51
|
+
0x2028: "LINE SEPARATOR",
|
|
52
|
+
0x2029: "PARAGRAPH SEPARATOR",
|
|
53
|
+
0x202f: "NARROW NO-BREAK SPACE",
|
|
54
|
+
0x205f: "MEDIUM MATHEMATICAL SPACE",
|
|
55
|
+
0x2060: "WORD JOINER",
|
|
56
|
+
0x3000: "IDEOGRAPHIC SPACE",
|
|
57
|
+
0xfeff: "ZERO WIDTH NO-BREAK SPACE",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Visible characters an author reaches for on the keyboard, and the
|
|
61
|
+
* typographic look-alikes a CMS, a text editor or `Intl` substitutes for
|
|
62
|
+
* them. Reported, never matched through — two strings that differ by an em
|
|
63
|
+
* dash really are different strings. */
|
|
64
|
+
const LOOKALIKES: Record<number, { ascii: string; name: string }> = {
|
|
65
|
+
0x2010: { ascii: "-", name: "HYPHEN" },
|
|
66
|
+
0x2011: { ascii: "-", name: "NON-BREAKING HYPHEN" },
|
|
67
|
+
0x2012: { ascii: "-", name: "FIGURE DASH" },
|
|
68
|
+
0x2013: { ascii: "-", name: "EN DASH" },
|
|
69
|
+
0x2014: { ascii: "-", name: "EM DASH" },
|
|
70
|
+
0x2212: { ascii: "-", name: "MINUS SIGN" },
|
|
71
|
+
0x2018: { ascii: "'", name: "LEFT SINGLE QUOTATION MARK" },
|
|
72
|
+
0x2019: { ascii: "'", name: "RIGHT SINGLE QUOTATION MARK" },
|
|
73
|
+
0x201c: { ascii: '"', name: "LEFT DOUBLE QUOTATION MARK" },
|
|
74
|
+
0x201d: { ascii: '"', name: "RIGHT DOUBLE QUOTATION MARK" },
|
|
75
|
+
0x2026: { ascii: "...", name: "HORIZONTAL ELLIPSIS" },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Playwright's whitespace normalization, character for character
|
|
80
|
+
* (`normalizeWhiteSpace`, playwright-core): drop zero-width spaces and soft
|
|
81
|
+
* hyphens, trim, then collapse every run of whitespace to one space. `\s`
|
|
82
|
+
* covers U+00A0 and the rest of the Unicode space characters, which is what
|
|
83
|
+
* makes a typed space match a no-break one.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeWhiteSpace(text: string): string {
|
|
86
|
+
return text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface TextMatchOptions {
|
|
90
|
+
/** Compare without regard to letter case. On a RegExp this adds the `i`
|
|
91
|
+
* flag, as playwright's own `ignoreCase` does. */
|
|
92
|
+
ignoreCase?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function caseFold(s: string, opts?: TextMatchOptions): string {
|
|
96
|
+
return opts?.ignoreCase ? s.toLowerCase() : s;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withIgnoreCase(re: RegExp, opts?: TextMatchOptions): RegExp {
|
|
100
|
+
if (!opts?.ignoreCase || re.flags.includes("i")) return re;
|
|
101
|
+
return new RegExp(re.source, `${re.flags}i`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The element's text equals `expected`, with whitespace normalized on both
|
|
105
|
+
* sides. A RegExp is tested against the normalized text. */
|
|
106
|
+
export function matchesText(
|
|
107
|
+
actual: string,
|
|
108
|
+
expected: string | RegExp,
|
|
109
|
+
opts?: TextMatchOptions,
|
|
110
|
+
): boolean {
|
|
111
|
+
const a = normalizeWhiteSpace(actual);
|
|
112
|
+
if (expected instanceof RegExp) return withIgnoreCase(expected, opts).test(a);
|
|
113
|
+
return caseFold(a, opts) === caseFold(normalizeWhiteSpace(expected), opts);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The element's text contains `expected`, with whitespace normalized on both
|
|
117
|
+
* sides. A RegExp is tested against the normalized text. */
|
|
118
|
+
export function containsText(
|
|
119
|
+
actual: string,
|
|
120
|
+
expected: string | RegExp,
|
|
121
|
+
opts?: TextMatchOptions,
|
|
122
|
+
): boolean {
|
|
123
|
+
const a = normalizeWhiteSpace(actual);
|
|
124
|
+
if (expected instanceof RegExp) return withIgnoreCase(expected, opts).test(a);
|
|
125
|
+
return caseFold(a, opts).includes(caseFold(normalizeWhiteSpace(expected), opts));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Diagnostics ───────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** `" "` for an invisible character, the character itself otherwise.
|
|
131
|
+
* Applied to an ALREADY `JSON.stringify`-ed string, so the result stays a
|
|
132
|
+
* valid JSON string literal. */
|
|
133
|
+
export function escapeInvisible(s: string): string {
|
|
134
|
+
let out = "";
|
|
135
|
+
for (const ch of s) {
|
|
136
|
+
const code = ch.codePointAt(0)!;
|
|
137
|
+
// Control characters are already escaped by JSON.stringify; what is left
|
|
138
|
+
// is the invisible-but-not-control set, which prints as nothing (or as an
|
|
139
|
+
// ordinary space) and so must be spelled out.
|
|
140
|
+
out += code > 0x1f && code in INVISIBLE_NAMES
|
|
141
|
+
? `\\u${code.toString(16).padStart(4, "0")}`
|
|
142
|
+
: ch;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The name of a character worth reporting, or `undefined` when it is an
|
|
148
|
+
* ordinary one. */
|
|
149
|
+
function suspectName(code: number): string | undefined {
|
|
150
|
+
if (code === 0x20) return undefined;
|
|
151
|
+
return INVISIBLE_NAMES[code] ?? LOOKALIKES[code]?.name;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Map a character onto the plain one it prints as, so two strings that only
|
|
155
|
+
* *look* the same can be told apart from two that really differ. */
|
|
156
|
+
function foldChar(ch: string): string {
|
|
157
|
+
const code = ch.codePointAt(0)!;
|
|
158
|
+
if (code in LOOKALIKES) return LOOKALIKES[code]!.ascii;
|
|
159
|
+
if (code in INVISIBLE_NAMES) {
|
|
160
|
+
// Zero-width characters print as nothing; the rest print as a space.
|
|
161
|
+
return code === 0x200b ||
|
|
162
|
+
code === 0x200c ||
|
|
163
|
+
code === 0x200d ||
|
|
164
|
+
code === 0x200e ||
|
|
165
|
+
code === 0x200f ||
|
|
166
|
+
code === 0x00ad ||
|
|
167
|
+
code === 0x2060 ||
|
|
168
|
+
code === 0xfeff
|
|
169
|
+
? ""
|
|
170
|
+
: " ";
|
|
171
|
+
}
|
|
172
|
+
return ch;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The string as it appears on screen: every look-alike replaced by the plain
|
|
176
|
+
* character it resembles, every zero-width one dropped, whitespace collapsed. */
|
|
177
|
+
function fold(s: string): string {
|
|
178
|
+
let out = "";
|
|
179
|
+
for (const ch of s) out += foldChar(ch);
|
|
180
|
+
return out.trim().replace(/ +/g, " ");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface Suspect {
|
|
184
|
+
name: string;
|
|
185
|
+
index: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every reportable character in `s`, first occurrence only, in order. */
|
|
189
|
+
function suspects(s: string): Suspect[] {
|
|
190
|
+
const seen = new Set<string>();
|
|
191
|
+
const out: Suspect[] = [];
|
|
192
|
+
let index = 0;
|
|
193
|
+
for (const ch of s) {
|
|
194
|
+
const name = suspectName(ch.codePointAt(0)!);
|
|
195
|
+
if (name && !seen.has(name)) {
|
|
196
|
+
seen.add(name);
|
|
197
|
+
out.push({ name, index });
|
|
198
|
+
}
|
|
199
|
+
index += ch.length;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function listSuspects(items: Suspect[]): string {
|
|
205
|
+
return items
|
|
206
|
+
.slice(0, 3)
|
|
207
|
+
.map((s) => `${s.name} at index ${s.index}`)
|
|
208
|
+
.join(", ");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A sentence to append to a failed string comparison when the two strings
|
|
213
|
+
* print the same, or differ only in letter case — the failure a reader cannot
|
|
214
|
+
* see. Returns `""` for every other case, so a caller can concatenate it
|
|
215
|
+
* unconditionally.
|
|
216
|
+
*
|
|
217
|
+
* `mode` is how the two were compared: `"equal"` for the equality matchers,
|
|
218
|
+
* `"contains"` for the substring ones.
|
|
219
|
+
*/
|
|
220
|
+
export function textDifferenceNote(
|
|
221
|
+
actual: unknown,
|
|
222
|
+
expected: unknown,
|
|
223
|
+
mode: "equal" | "contains",
|
|
224
|
+
): string {
|
|
225
|
+
if (typeof actual !== "string" || typeof expected !== "string") return "";
|
|
226
|
+
const matched = (a: string, e: string): boolean =>
|
|
227
|
+
mode === "equal" ? a === e : a.includes(e);
|
|
228
|
+
if (matched(actual, expected)) return "";
|
|
229
|
+
|
|
230
|
+
const fa = fold(actual);
|
|
231
|
+
const fe = fold(expected);
|
|
232
|
+
if (matched(fa, fe)) {
|
|
233
|
+
const inActual = suspects(actual);
|
|
234
|
+
const inExpected = suspects(expected);
|
|
235
|
+
const where = inActual.length
|
|
236
|
+
? `the actual text has ${listSuspects(inActual)}`
|
|
237
|
+
: `the expected string has ${listSuspects(inExpected)}`;
|
|
238
|
+
return ` — the two print the same: they differ only in invisible or look-alike characters (${where})`;
|
|
239
|
+
}
|
|
240
|
+
if (matched(fa.toLowerCase(), fe.toLowerCase())) {
|
|
241
|
+
return " — they differ only in letter case";
|
|
242
|
+
}
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Array forms (`toHaveText([...])` / `toContainText([...])`) ────────────
|
|
247
|
+
//
|
|
248
|
+
// Playwright's own rule, from its injected script: the expected items are
|
|
249
|
+
// matched against the received texts **greedily, in order**, and the lengths
|
|
250
|
+
// must agree for `toHaveText` but not for `toContainText`. So
|
|
251
|
+
// `toContainText(["Two", "Four"])` passes against a four-row list, while
|
|
252
|
+
// `toHaveText(["Two", "Four"])` does not.
|
|
253
|
+
|
|
254
|
+
function matchesInOrder(
|
|
255
|
+
actual: string[],
|
|
256
|
+
expected: (string | RegExp)[],
|
|
257
|
+
item: (a: string, e: string | RegExp) => boolean,
|
|
258
|
+
): boolean {
|
|
259
|
+
let want = 0;
|
|
260
|
+
for (let i = 0; i < actual.length && want < expected.length; i++) {
|
|
261
|
+
if (item(actual[i]!, expected[want]!)) want++;
|
|
262
|
+
}
|
|
263
|
+
return want === expected.length;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Every element's text equals the item at its position, and there are
|
|
267
|
+
* exactly as many elements as items. */
|
|
268
|
+
export function matchesTextArray(
|
|
269
|
+
actual: string[],
|
|
270
|
+
expected: (string | RegExp)[],
|
|
271
|
+
opts?: TextMatchOptions,
|
|
272
|
+
): boolean {
|
|
273
|
+
if (actual.length !== expected.length) return false;
|
|
274
|
+
return matchesInOrder(actual, expected, (a, e) => matchesText(a, e, opts));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The elements' texts contain every item, in order. Extra elements between
|
|
278
|
+
* the matches are allowed. */
|
|
279
|
+
export function containsTextArray(
|
|
280
|
+
actual: string[],
|
|
281
|
+
expected: (string | RegExp)[],
|
|
282
|
+
opts?: TextMatchOptions,
|
|
283
|
+
): boolean {
|
|
284
|
+
return matchesInOrder(actual, expected, (a, e) => containsText(a, e, opts));
|
|
285
|
+
}
|