@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.
Files changed (40) hide show
  1. package/dist/browser.js +42 -1
  2. package/dist/components/supabase.d.ts +0 -14
  3. package/dist/components/supabase.js +2 -8
  4. package/dist/daemon.js +155 -35
  5. package/dist/harness/intercept.d.ts +22 -0
  6. package/dist/harness/intercept.js +29 -0
  7. package/dist/harness/wrapper-rules.d.ts +149 -0
  8. package/dist/harness/wrapper-rules.js +422 -0
  9. package/dist/index.d.ts +52 -16
  10. package/dist/index.js +76 -17
  11. package/dist/locator-errors.d.ts +19 -10
  12. package/dist/locator-errors.js +80 -20
  13. package/dist/locator-hints.d.ts +96 -0
  14. package/dist/locator-hints.js +403 -0
  15. package/dist/locator.d.ts +22 -0
  16. package/dist/locator.js +63 -9
  17. package/dist/page-snapshot.d.ts +42 -0
  18. package/dist/page-snapshot.js +149 -0
  19. package/dist/recorder.d.ts +16 -0
  20. package/dist/text-match.d.ts +39 -0
  21. package/dist/text-match.js +239 -0
  22. package/package.json +1 -1
  23. package/src/browser.ts +43 -1
  24. package/src/components/supabase.ts +2 -20
  25. package/src/daemon.ts +171 -34
  26. package/src/harness/intercept.test.ts +36 -0
  27. package/src/harness/intercept.ts +40 -0
  28. package/src/harness/wrapper-rules.test.ts +170 -0
  29. package/src/harness/wrapper-rules.ts +547 -0
  30. package/src/index.ts +159 -32
  31. package/src/locator-errors.test.ts +99 -11
  32. package/src/locator-errors.ts +98 -19
  33. package/src/locator-hints.test.ts +188 -0
  34. package/src/locator-hints.ts +514 -0
  35. package/src/locator.ts +72 -9
  36. package/src/page-snapshot.test.ts +100 -0
  37. package/src/page-snapshot.ts +180 -0
  38. package/src/recorder.ts +16 -0
  39. package/src/text-match.test.ts +132 -0
  40. 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
+ }