@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,149 @@
1
+ // The page's structure at the moment a browser step failed, as text an agent
2
+ // can read.
3
+ //
4
+ // A failed browser test is the one failure where the CLI reader has nothing to
5
+ // look at. The message says what did not happen, the timeline says what ran,
6
+ // and the page itself — the thing that would answer the question — is only on
7
+ // the dashboard, inside an rrweb replay. `screenshot()` is no help either: it
8
+ // throws inside a test run, and an image is not something every reader can
9
+ // read.
10
+ //
11
+ // So a failed browser/mobile step captures its page as an **accessibility
12
+ // tree** (`page.ariaSnapshot()`), the same data locators resolve against. It is
13
+ // small (a form page is under 1 KB; a 200-row table is 38 KB, 4 KB gzipped), it
14
+ // is text, and every name in it is directly usable as
15
+ // `getByRole(role, { name })`. It rides out on the step's own recorded event,
16
+ // which is what keeps this entirely inside the SDK: no new daemon method, no
17
+ // side channel, no supervisor involvement. The control plane lifts it into a
18
+ // `case-page` artifact the same way it already lifts an eval screenshot's
19
+ // bytes, so the failure block can hand the reader one command that prints the
20
+ // page they could not see.
21
+ //
22
+ // Riding the event has a second, load-bearing effect: `ctx.poll` truncates the
23
+ // events of every superseded attempt, so a poll whose predicate fails ten
24
+ // times keeps exactly one capture — the attempt the timeline kept — with no
25
+ // bookkeeping here.
26
+ //
27
+ // Two deliberate choices about the format:
28
+ //
29
+ // * **Default mode, not `mode: "ai"`.** The ai mode adds `[ref=e12]`
30
+ // handles, and a ref is only resolvable in the live page it came from —
31
+ // in a downloaded file it is an invitation to write a locator that cannot
32
+ // work. Iframe content, which ai mode adds and the default mode leaves as
33
+ // a bare `- iframe`, is recovered by snapshotting each frame instead.
34
+ // * **No timestamps.** A guest's wall clock resumes frozen at the snapshot
35
+ // it was restored from, so a time captured here would be a plausible lie.
36
+ // The artifact row carries a host-side `created_at`.
37
+ /** The document is a debugging aid, and a pathological page must not grow the
38
+ * `/run` reply (the vm-agent caps a proxied daemon response at 16 MB). Real
39
+ * pages are far below this: the biggest thing measured while building it was
40
+ * 38 KB. */
41
+ export const MAX_DOCUMENT_BYTES = 256 * 1024;
42
+ /** How many child frames are worth walking. An ad-heavy page can carry
43
+ * dozens; the ones a test drives are at the front. */
44
+ const MAX_FRAMES = 10;
45
+ /** Per-call deadline. The page has just failed a step, and it may be wedged; a
46
+ * diagnostic must not add to the damage. */
47
+ const CAPTURE_TIMEOUT_MS = 3_000;
48
+ function withTimeout(work, fallback) {
49
+ let timer;
50
+ return Promise.race([
51
+ work.catch(() => fallback),
52
+ new Promise((resolve) => {
53
+ timer = setTimeout(() => resolve(fallback), CAPTURE_TIMEOUT_MS);
54
+ }),
55
+ ]).finally(() => {
56
+ if (timer)
57
+ clearTimeout(timer);
58
+ });
59
+ }
60
+ async function frameStructure(frame) {
61
+ // A frame's tree comes from its own body: `page.ariaSnapshot()` stops at
62
+ // `- iframe` and never descends into it.
63
+ const tree = await withTimeout(frame.locator("body").ariaSnapshot(), "");
64
+ if (!tree.trim())
65
+ return undefined;
66
+ return { name: frame.name(), url: frame.url(), tree };
67
+ }
68
+ /**
69
+ * Capture the page behind a failed step, or `undefined` when there is nothing
70
+ * to show.
71
+ *
72
+ * Everything here is best-effort: a page that is closed, navigating or wedged
73
+ * yields a partial record rather than an error, because the caller is already
74
+ * reporting a failure and must not report this one instead.
75
+ */
76
+ export async function capturePageStructure(page, meta) {
77
+ const tree = await withTimeout(page.ariaSnapshot(), "");
78
+ const title = await withTimeout(page.title(), "");
79
+ let url = "";
80
+ try {
81
+ url = page.url();
82
+ }
83
+ catch {
84
+ /* The page is gone; the tree, if we got one, is still worth keeping. */
85
+ }
86
+ const frames = [];
87
+ try {
88
+ const children = page.frames().filter((f) => f !== page.mainFrame());
89
+ for (const f of children.slice(0, MAX_FRAMES)) {
90
+ const s = await frameStructure(f);
91
+ if (s)
92
+ frames.push(s);
93
+ }
94
+ }
95
+ catch {
96
+ /* Frames are a bonus; the main tree is the point. */
97
+ }
98
+ if (!tree.trim() && frames.length === 0)
99
+ return undefined;
100
+ return formatPageStructure({ ...meta, url, title, tree, frames });
101
+ }
102
+ /** The header that tells a reader what they are looking at. Worth its lines:
103
+ * the file is read by someone who did not choose its format, hours later,
104
+ * with nothing around it. */
105
+ const LEGEND = [
106
+ "# Page structure at the moment this step failed.",
107
+ "#",
108
+ '# One line per accessibility node: `role "accessible name" [state]: own text`,',
109
+ "# children indented, properties as `/`-prefixed children (/url, /placeholder).",
110
+ "# The names here are exactly what `getByRole(role, { name })` matches on.",
111
+ "# Elements hidden from assistive technology are absent, as they are from locators.",
112
+ ];
113
+ function quote(s) {
114
+ return JSON.stringify(s);
115
+ }
116
+ function sessionHeading(s) {
117
+ const name = s.session === "" ? "(default)" : quote(s.session);
118
+ const parts = [`## ${s.kind} session ${name}`];
119
+ if (s.url)
120
+ parts.push(`url: ${s.url}`);
121
+ if (s.title)
122
+ parts.push(`title: ${quote(s.title)}`);
123
+ return parts.join(" — ");
124
+ }
125
+ /** Render the document. Pure, so its shape is testable without a browser. */
126
+ export function formatPageStructure(s) {
127
+ const lines = [...LEGEND, "#", `# failed step: ${s.action}`];
128
+ if (s.error) {
129
+ // The first line only: the near-miss block under a locator failure is
130
+ // already in the CLI output, and this file exists to add to it.
131
+ lines.push(`# failure: ${s.error.split("\n")[0]}`);
132
+ }
133
+ lines.push("", sessionHeading(s), s.tree.trimEnd());
134
+ for (const f of s.frames) {
135
+ const name = f.name === "" ? "" : ` ${quote(f.name)}`;
136
+ lines.push("", `### iframe${name} — url: ${f.url}`, f.tree.trimEnd());
137
+ }
138
+ return truncate(`${lines.join("\n")}\n`);
139
+ }
140
+ /** Cut the document to {@link MAX_DOCUMENT_BYTES}, on a line boundary, and say
141
+ * so — a silently short tree reads as a page that ends there. */
142
+ export function truncate(doc) {
143
+ const bytes = Buffer.byteLength(doc, "utf8");
144
+ if (bytes <= MAX_DOCUMENT_BYTES)
145
+ return doc;
146
+ const text = Buffer.from(doc, "utf8").subarray(0, MAX_DOCUMENT_BYTES).toString("utf8");
147
+ const cut = text.slice(0, text.lastIndexOf("\n") + 1);
148
+ return `${cut}# … truncated at ${MAX_DOCUMENT_BYTES} bytes (${bytes} bytes captured)\n`;
149
+ }
@@ -433,6 +433,22 @@ export interface BrowserEvent extends BaseEvent {
433
433
  attempts?: number;
434
434
  durationMs: number;
435
435
  error?: string;
436
+ /**
437
+ * The page's accessibility tree at the moment this step failed — set only
438
+ * on a failed browser/mobile step (see `page-snapshot.ts`). It rides the
439
+ * event because that is the only channel out of the harness the SDK owns
440
+ * end to end, and because `ctx.poll` truncates a superseded attempt's
441
+ * events, which drops its capture with it.
442
+ *
443
+ * **The control plane lifts this out**: it stores the text as a
444
+ * `case-page` artifact and replaces the field with `pageArtifactId`,
445
+ * exactly as it does for an eval screenshot's inline bytes. So a
446
+ * persisted event carries the id, never the document.
447
+ */
448
+ pageStructure?: string;
449
+ /** The `art_0…` the control plane stored {@link pageStructure} as. Never
450
+ * set by the SDK. */
451
+ pageArtifactId?: string;
436
452
  /**
437
453
  * Session this op belonged to. Set whenever the Browser was opened
438
454
  * with a `BrowserSessionRecorder` attached (the daemon always does).
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Playwright's whitespace normalization, character for character
3
+ * (`normalizeWhiteSpace`, playwright-core): drop zero-width spaces and soft
4
+ * hyphens, trim, then collapse every run of whitespace to one space. `\s`
5
+ * covers U+00A0 and the rest of the Unicode space characters, which is what
6
+ * makes a typed space match a no-break one.
7
+ */
8
+ export declare function normalizeWhiteSpace(text: string): string;
9
+ export interface TextMatchOptions {
10
+ /** Compare without regard to letter case. On a RegExp this adds the `i`
11
+ * flag, as playwright's own `ignoreCase` does. */
12
+ ignoreCase?: boolean;
13
+ }
14
+ /** The element's text equals `expected`, with whitespace normalized on both
15
+ * sides. A RegExp is tested against the normalized text. */
16
+ export declare function matchesText(actual: string, expected: string | RegExp, opts?: TextMatchOptions): boolean;
17
+ /** The element's text contains `expected`, with whitespace normalized on both
18
+ * sides. A RegExp is tested against the normalized text. */
19
+ export declare function containsText(actual: string, expected: string | RegExp, opts?: TextMatchOptions): boolean;
20
+ /** `" "` for an invisible character, the character itself otherwise.
21
+ * Applied to an ALREADY `JSON.stringify`-ed string, so the result stays a
22
+ * valid JSON string literal. */
23
+ export declare function escapeInvisible(s: string): string;
24
+ /**
25
+ * A sentence to append to a failed string comparison when the two strings
26
+ * print the same, or differ only in letter case — the failure a reader cannot
27
+ * see. Returns `""` for every other case, so a caller can concatenate it
28
+ * unconditionally.
29
+ *
30
+ * `mode` is how the two were compared: `"equal"` for the equality matchers,
31
+ * `"contains"` for the substring ones.
32
+ */
33
+ export declare function textDifferenceNote(actual: unknown, expected: unknown, mode: "equal" | "contains"): string;
34
+ /** Every element's text equals the item at its position, and there are
35
+ * exactly as many elements as items. */
36
+ export declare function matchesTextArray(actual: string[], expected: (string | RegExp)[], opts?: TextMatchOptions): boolean;
37
+ /** The elements' texts contain every item, in order. Extra elements between
38
+ * the matches are allowed. */
39
+ export declare function containsTextArray(actual: string[], expected: (string | RegExp)[], opts?: TextMatchOptions): boolean;
@@ -0,0 +1,239 @@
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
+ /** Characters that are invisible on screen (or print as an ordinary space)
24
+ * and their Unicode names, for a message a reader can act on. */
25
+ const INVISIBLE_NAMES = {
26
+ 0x0009: "TAB",
27
+ 0x000a: "LINE FEED",
28
+ 0x000b: "LINE TABULATION",
29
+ 0x000c: "FORM FEED",
30
+ 0x000d: "CARRIAGE RETURN",
31
+ 0x00a0: "NO-BREAK SPACE",
32
+ 0x00ad: "SOFT HYPHEN",
33
+ 0x1680: "OGHAM SPACE MARK",
34
+ 0x2000: "EN QUAD",
35
+ 0x2001: "EM QUAD",
36
+ 0x2002: "EN SPACE",
37
+ 0x2003: "EM SPACE",
38
+ 0x2004: "THREE-PER-EM SPACE",
39
+ 0x2005: "FOUR-PER-EM SPACE",
40
+ 0x2006: "SIX-PER-EM SPACE",
41
+ 0x2007: "FIGURE SPACE",
42
+ 0x2008: "PUNCTUATION SPACE",
43
+ 0x2009: "THIN SPACE",
44
+ 0x200a: "HAIR SPACE",
45
+ 0x200b: "ZERO WIDTH SPACE",
46
+ 0x200c: "ZERO WIDTH NON-JOINER",
47
+ 0x200d: "ZERO WIDTH JOINER",
48
+ 0x200e: "LEFT-TO-RIGHT MARK",
49
+ 0x200f: "RIGHT-TO-LEFT MARK",
50
+ 0x2028: "LINE SEPARATOR",
51
+ 0x2029: "PARAGRAPH SEPARATOR",
52
+ 0x202f: "NARROW NO-BREAK SPACE",
53
+ 0x205f: "MEDIUM MATHEMATICAL SPACE",
54
+ 0x2060: "WORD JOINER",
55
+ 0x3000: "IDEOGRAPHIC SPACE",
56
+ 0xfeff: "ZERO WIDTH NO-BREAK SPACE",
57
+ };
58
+ /** Visible characters an author reaches for on the keyboard, and the
59
+ * typographic look-alikes a CMS, a text editor or `Intl` substitutes for
60
+ * them. Reported, never matched through — two strings that differ by an em
61
+ * dash really are different strings. */
62
+ const LOOKALIKES = {
63
+ 0x2010: { ascii: "-", name: "HYPHEN" },
64
+ 0x2011: { ascii: "-", name: "NON-BREAKING HYPHEN" },
65
+ 0x2012: { ascii: "-", name: "FIGURE DASH" },
66
+ 0x2013: { ascii: "-", name: "EN DASH" },
67
+ 0x2014: { ascii: "-", name: "EM DASH" },
68
+ 0x2212: { ascii: "-", name: "MINUS SIGN" },
69
+ 0x2018: { ascii: "'", name: "LEFT SINGLE QUOTATION MARK" },
70
+ 0x2019: { ascii: "'", name: "RIGHT SINGLE QUOTATION MARK" },
71
+ 0x201c: { ascii: '"', name: "LEFT DOUBLE QUOTATION MARK" },
72
+ 0x201d: { ascii: '"', name: "RIGHT DOUBLE QUOTATION MARK" },
73
+ 0x2026: { ascii: "...", name: "HORIZONTAL ELLIPSIS" },
74
+ };
75
+ /**
76
+ * Playwright's whitespace normalization, character for character
77
+ * (`normalizeWhiteSpace`, playwright-core): drop zero-width spaces and soft
78
+ * hyphens, trim, then collapse every run of whitespace to one space. `\s`
79
+ * covers U+00A0 and the rest of the Unicode space characters, which is what
80
+ * makes a typed space match a no-break one.
81
+ */
82
+ export function normalizeWhiteSpace(text) {
83
+ return text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " ");
84
+ }
85
+ function caseFold(s, opts) {
86
+ return opts?.ignoreCase ? s.toLowerCase() : s;
87
+ }
88
+ function withIgnoreCase(re, opts) {
89
+ if (!opts?.ignoreCase || re.flags.includes("i"))
90
+ return re;
91
+ return new RegExp(re.source, `${re.flags}i`);
92
+ }
93
+ /** The element's text equals `expected`, with whitespace normalized on both
94
+ * sides. A RegExp is tested against the normalized text. */
95
+ export function matchesText(actual, expected, opts) {
96
+ const a = normalizeWhiteSpace(actual);
97
+ if (expected instanceof RegExp)
98
+ return withIgnoreCase(expected, opts).test(a);
99
+ return caseFold(a, opts) === caseFold(normalizeWhiteSpace(expected), opts);
100
+ }
101
+ /** The element's text contains `expected`, with whitespace normalized on both
102
+ * sides. A RegExp is tested against the normalized text. */
103
+ export function containsText(actual, expected, opts) {
104
+ const a = normalizeWhiteSpace(actual);
105
+ if (expected instanceof RegExp)
106
+ return withIgnoreCase(expected, opts).test(a);
107
+ return caseFold(a, opts).includes(caseFold(normalizeWhiteSpace(expected), opts));
108
+ }
109
+ // ── Diagnostics ───────────────────────────────────────────────────────────
110
+ /** `" "` for an invisible character, the character itself otherwise.
111
+ * Applied to an ALREADY `JSON.stringify`-ed string, so the result stays a
112
+ * valid JSON string literal. */
113
+ export function escapeInvisible(s) {
114
+ let out = "";
115
+ for (const ch of s) {
116
+ const code = ch.codePointAt(0);
117
+ // Control characters are already escaped by JSON.stringify; what is left
118
+ // is the invisible-but-not-control set, which prints as nothing (or as an
119
+ // ordinary space) and so must be spelled out.
120
+ out += code > 0x1f && code in INVISIBLE_NAMES
121
+ ? `\\u${code.toString(16).padStart(4, "0")}`
122
+ : ch;
123
+ }
124
+ return out;
125
+ }
126
+ /** The name of a character worth reporting, or `undefined` when it is an
127
+ * ordinary one. */
128
+ function suspectName(code) {
129
+ if (code === 0x20)
130
+ return undefined;
131
+ return INVISIBLE_NAMES[code] ?? LOOKALIKES[code]?.name;
132
+ }
133
+ /** Map a character onto the plain one it prints as, so two strings that only
134
+ * *look* the same can be told apart from two that really differ. */
135
+ function foldChar(ch) {
136
+ const code = ch.codePointAt(0);
137
+ if (code in LOOKALIKES)
138
+ return LOOKALIKES[code].ascii;
139
+ if (code in INVISIBLE_NAMES) {
140
+ // Zero-width characters print as nothing; the rest print as a space.
141
+ return code === 0x200b ||
142
+ code === 0x200c ||
143
+ code === 0x200d ||
144
+ code === 0x200e ||
145
+ code === 0x200f ||
146
+ code === 0x00ad ||
147
+ code === 0x2060 ||
148
+ code === 0xfeff
149
+ ? ""
150
+ : " ";
151
+ }
152
+ return ch;
153
+ }
154
+ /** The string as it appears on screen: every look-alike replaced by the plain
155
+ * character it resembles, every zero-width one dropped, whitespace collapsed. */
156
+ function fold(s) {
157
+ let out = "";
158
+ for (const ch of s)
159
+ out += foldChar(ch);
160
+ return out.trim().replace(/ +/g, " ");
161
+ }
162
+ /** Every reportable character in `s`, first occurrence only, in order. */
163
+ function suspects(s) {
164
+ const seen = new Set();
165
+ const out = [];
166
+ let index = 0;
167
+ for (const ch of s) {
168
+ const name = suspectName(ch.codePointAt(0));
169
+ if (name && !seen.has(name)) {
170
+ seen.add(name);
171
+ out.push({ name, index });
172
+ }
173
+ index += ch.length;
174
+ }
175
+ return out;
176
+ }
177
+ function listSuspects(items) {
178
+ return items
179
+ .slice(0, 3)
180
+ .map((s) => `${s.name} at index ${s.index}`)
181
+ .join(", ");
182
+ }
183
+ /**
184
+ * A sentence to append to a failed string comparison when the two strings
185
+ * print the same, or differ only in letter case — the failure a reader cannot
186
+ * see. Returns `""` for every other case, so a caller can concatenate it
187
+ * unconditionally.
188
+ *
189
+ * `mode` is how the two were compared: `"equal"` for the equality matchers,
190
+ * `"contains"` for the substring ones.
191
+ */
192
+ export function textDifferenceNote(actual, expected, mode) {
193
+ if (typeof actual !== "string" || typeof expected !== "string")
194
+ return "";
195
+ const matched = (a, e) => mode === "equal" ? a === e : a.includes(e);
196
+ if (matched(actual, expected))
197
+ return "";
198
+ const fa = fold(actual);
199
+ const fe = fold(expected);
200
+ if (matched(fa, fe)) {
201
+ const inActual = suspects(actual);
202
+ const inExpected = suspects(expected);
203
+ const where = inActual.length
204
+ ? `the actual text has ${listSuspects(inActual)}`
205
+ : `the expected string has ${listSuspects(inExpected)}`;
206
+ return ` — the two print the same: they differ only in invisible or look-alike characters (${where})`;
207
+ }
208
+ if (matched(fa.toLowerCase(), fe.toLowerCase())) {
209
+ return " — they differ only in letter case";
210
+ }
211
+ return "";
212
+ }
213
+ // ── Array forms (`toHaveText([...])` / `toContainText([...])`) ────────────
214
+ //
215
+ // Playwright's own rule, from its injected script: the expected items are
216
+ // matched against the received texts **greedily, in order**, and the lengths
217
+ // must agree for `toHaveText` but not for `toContainText`. So
218
+ // `toContainText(["Two", "Four"])` passes against a four-row list, while
219
+ // `toHaveText(["Two", "Four"])` does not.
220
+ function matchesInOrder(actual, expected, item) {
221
+ let want = 0;
222
+ for (let i = 0; i < actual.length && want < expected.length; i++) {
223
+ if (item(actual[i], expected[want]))
224
+ want++;
225
+ }
226
+ return want === expected.length;
227
+ }
228
+ /** Every element's text equals the item at its position, and there are
229
+ * exactly as many elements as items. */
230
+ export function matchesTextArray(actual, expected, opts) {
231
+ if (actual.length !== expected.length)
232
+ return false;
233
+ return matchesInOrder(actual, expected, (a, e) => matchesText(a, e, opts));
234
+ }
235
+ /** The elements' texts contain every item, in order. Extra elements between
236
+ * the matches are allowed. */
237
+ export function containsTextArray(actual, expected, opts) {
238
+ return matchesInOrder(actual, expected, (a, e) => containsText(a, e, opts));
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.66.0",
3
+ "version": "0.68.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/browser.ts CHANGED
@@ -37,6 +37,7 @@ import { generateId } from "./ids.js";
37
37
  import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
38
38
  import { wrap } from "./inspect.js";
39
39
  import type { Wrapped } from "./inspect.js";
40
+ import { capturePageStructure } from "./page-snapshot.js";
40
41
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
41
42
  import {
42
43
  attachBrowserCoverage,
@@ -1616,6 +1617,38 @@ function buildBackend(
1616
1617
  // when rrweb's load-deferred full snapshot is most likely to be
1617
1618
  // missing from the chunk we're about to drain (see `drainExpr`).
1618
1619
  let lastDrainUrl: string | null = null;
1620
+ // Failed steps that captured their page. Bounded per session per case: a
1621
+ // test that catches its own locator errors in a loop must not pay for a
1622
+ // capture on each one, and three pages is already more than a reader will
1623
+ // open. `ctx.poll` needs no bookkeeping here — it truncates the events of
1624
+ // every superseded attempt, and each capture rides its own event.
1625
+ let pageCaptures = 0;
1626
+ const PAGE_CAPTURE_LIMIT = 3;
1627
+
1628
+ /** The page behind a failed step, for its event (see page-snapshot.ts).
1629
+ * Best-effort and quiet: this runs while an error is already on its way
1630
+ * up, and must never become the failure the author sees. */
1631
+ async function failedPageStructure(
1632
+ action: BrowserAction,
1633
+ error: string,
1634
+ ): Promise<string | undefined> {
1635
+ // No recorder means nothing is recording this op (an eval, a library
1636
+ // caller), so there is no event for the capture to ride out on.
1637
+ if (!recorder || recordingEnded || viewClosed) return undefined;
1638
+ if (pageCaptures >= PAGE_CAPTURE_LIMIT) return undefined;
1639
+ pageCaptures += 1;
1640
+ try {
1641
+ return await capturePageStructure(holder.page, {
1642
+ session: recorder.sessionName ?? "",
1643
+ // A desktop view has no device preset; a phone-emulated one does.
1644
+ kind: holder.device ? "mobile" : "browser",
1645
+ action,
1646
+ error,
1647
+ });
1648
+ } catch {
1649
+ return undefined;
1650
+ }
1651
+ }
1619
1652
 
1620
1653
  /** Session provenance stamped on every recorded op: which replay player
1621
1654
  * the step belongs to, which named browser it acted on, and where in
@@ -1699,12 +1732,17 @@ function buildBackend(
1699
1732
  } catch (err) {
1700
1733
  const e = err as Error;
1701
1734
  const endT = Date.now();
1735
+ const error = e?.message ?? String(err);
1736
+ // Captured after the clock stops, so the diagnostic is not billed to
1737
+ // the step, and before the event is recorded, so it rides it.
1738
+ const pageStructure = await failedPageStructure(action, error);
1702
1739
  recordBrowser({
1703
1740
  action,
1704
1741
  ...fields,
1705
1742
  ...sessionFields(endT),
1706
1743
  durationMs: endT - t,
1707
- error: e?.message ?? String(err),
1744
+ error,
1745
+ ...(pageStructure ? { pageStructure } : {}),
1708
1746
  }, resv);
1709
1747
  // Still try to drain — the failure itself may have produced
1710
1748
  // useful rrweb events (mutations from a half-loaded page, etc.).
@@ -2078,12 +2116,16 @@ function buildBackend(
2078
2116
  // where the matcher started polling, since that's what `tOffsetMs` means
2079
2117
  // everywhere else (the ops that reserve up front stamp their start).
2080
2118
  const endT = Date.now();
2119
+ // A matcher that ran out of budget is the other half of a failed
2120
+ // browser step, and the page it settled on is the same evidence.
2121
+ const pageStructure = error ? await failedPageStructure(action, error) : undefined;
2081
2122
  const seq = recordBrowser({
2082
2123
  action,
2083
2124
  ...fields,
2084
2125
  ...sessionFields(endT),
2085
2126
  durationMs: waitedMs,
2086
2127
  ...(error ? { error } : {}),
2128
+ ...(pageStructure ? { pageStructure } : {}),
2087
2129
  }, reserveBackdated(waitedMs));
2088
2130
  // Drain the rrweb the page buffered while the matcher waited into this
2089
2131
  // step's chunk, so `settledTarget` has bounds to seek into.
@@ -235,18 +235,6 @@ export interface SupabaseOptions {
235
235
  storage?: boolean;
236
236
  /** Include Realtime (`<name>-realtime`). Default `true`. */
237
237
  realtime?: boolean;
238
- /**
239
- * Edge functions. `isolation` picks how they run on the edge runtime:
240
- * `"shared"` (default) loads every function into **one** isolate, so a
241
- * shared module graph (`_shared/`, npm packages) is loaded once rather than
242
- * once per function — measured on a 51-function project as 230 MB against
243
- * 3.6 GB. Hosted Supabase runs one isolate per function, and the one thing
244
- * that differs is module-level state: a singleton in `_shared` is one
245
- * object for all functions here, one per function there. `"per-function"`
246
- * runs it the hosted way. Either way the runtime, the request each
247
- * function sees and the per-function `verify_jwt` are the same.
248
- */
249
- functions?: { isolation?: "shared" | "per-function" };
250
238
 
251
239
  /**
252
240
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
@@ -1055,9 +1043,6 @@ async function validJwt(token: string): Promise<boolean> {
1055
1043
  }
1056
1044
  }
1057
1045
 
1058
- /** How the functions run: every function in one isolate (the default), or
1059
- * one isolate per function as hosted Supabase does — see sharedWorker. */
1060
- const ISOLATION = Deno.env.get("FUNCTIONS_ISOLATION") === "per-function" ? "per-function" : "shared";
1061
1046
  /** Where the generated entry of the shared isolate lives — outside the repo
1062
1047
  * mount, which is read-only and the user's checkout, and NOT under /tmp:
1063
1048
  * the runtime gives its workers an in-memory /tmp, so a file the main
@@ -1137,7 +1122,8 @@ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
1137
1122
  // what differs from hosted Supabase is that module-level state is one
1138
1123
  // object for every function rather than one per function.
1139
1124
  //
1140
- // It stands in only where it can be exact. A function with its own
1125
+ // There is deliberately no opt-out: it stands in wherever it can be exact,
1126
+ // and where it cannot the per-function isolates are used. A function with its own
1141
1127
  // deno.json / import map (per-function resolution the one config cannot
1142
1128
  // express), a functions/deno.jsonc (comments; not merged) or a config that
1143
1129
  // names an importMap keep the per-function isolates, and so does a shared
@@ -1286,7 +1272,6 @@ async function writeShared(names: string[]): Promise<string | null> {
1286
1272
  /** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
1287
1273
  * the functions run one isolate each — decided once, with the reason logged. */
1288
1274
  async function sharedWorker(): Promise<any | null> {
1289
- if (ISOLATION !== "shared") return null;
1290
1275
  if (!sharedSpec) {
1291
1276
  sharedSpec = (async () => {
1292
1277
  const names = await functionNames();
@@ -2242,9 +2227,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2242
2227
  JWT_SECRET: jwtSecret,
2243
2228
  // Where the router looks for `<name>/`, inside the repo mount.
2244
2229
  FUNCTIONS_DIR: fnServePath,
2245
- // One isolate for all functions (default) or one each — see the
2246
- // `functions` option and the router's shared-isolate notes.
2247
- FUNCTIONS_ISOLATION: opts.functions?.isolation ?? "shared",
2248
2230
  // The default a function inherits when the config says nothing:
2249
2231
  // reject an unauthenticated call, as hosted Supabase does.
2250
2232
  VERIFY_JWT: "true",