@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.
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ formatPageStructure,
5
+ MAX_DOCUMENT_BYTES,
6
+ truncate,
7
+ type PageStructure,
8
+ } from "./page-snapshot.js";
9
+
10
+ /** The tree shape `page.ariaSnapshot()` really returns, captured from
11
+ * playwright-core 1.61.1 against headless Chromium (see the harness recipe in
12
+ * locator-hints.test.ts). */
13
+ const TREE = `- heading "Fakturor" [level=1]
14
+ - button "Spara ändringar": Spara
15
+ - combobox "Sortera":
16
+ - option "Datum" [selected]
17
+ - iframe`;
18
+
19
+ const base: PageStructure = {
20
+ session: "",
21
+ kind: "browser",
22
+ action: "click",
23
+ error: 'No element matches role button "Spara" (waited 5s)',
24
+ url: "http://app.test.local:3000/",
25
+ title: "Fakturor",
26
+ tree: TREE,
27
+ frames: [],
28
+ };
29
+
30
+ describe("formatPageStructure", () => {
31
+ test("leads with a legend, the failed step and the page it was on", () => {
32
+ const doc = formatPageStructure(base);
33
+ const lines = doc.split("\n");
34
+ expect(lines[0]).toBe("# Page structure at the moment this step failed.");
35
+ // The reader did not choose this format, so the file explains it.
36
+ expect(doc).toContain('`role "accessible name" [state]: own text`');
37
+ expect(doc).toContain("`getByRole(role, { name })`");
38
+ expect(doc).toContain("# failed step: click");
39
+ expect(doc).toContain(
40
+ '# failure: No element matches role button "Spara" (waited 5s)',
41
+ );
42
+ expect(doc).toContain(
43
+ '## browser session (default) — url: http://app.test.local:3000/ — title: "Fakturor"',
44
+ );
45
+ expect(doc).toContain('- button "Spara ändringar": Spara');
46
+ expect(doc.endsWith("\n")).toBe(true);
47
+ });
48
+
49
+ test("keeps only the first line of a multi-line failure", () => {
50
+ // A locator failure carries its near-miss block; that is already in the
51
+ // CLI output, and this file exists to add to it.
52
+ const doc = formatPageStructure({
53
+ ...base,
54
+ error: "No element matches role button \"Spara\"\nClose matches:\n - …",
55
+ });
56
+ expect(doc).toContain('# failure: No element matches role button "Spara"');
57
+ expect(doc).not.toContain("Close matches");
58
+ });
59
+
60
+ test("names the session when the test named it", () => {
61
+ const doc = formatPageStructure({ ...base, session: "alice", kind: "mobile" });
62
+ expect(doc).toContain('## mobile session "alice"');
63
+ });
64
+
65
+ test("renders each child frame under its own heading", () => {
66
+ const doc = formatPageStructure({
67
+ ...base,
68
+ frames: [
69
+ { name: "pay", url: "https://pay.test/form", tree: '- textbox "Kortnummer"' },
70
+ { name: "", url: "about:srcdoc", tree: "- button \"OK\"" },
71
+ ],
72
+ });
73
+ expect(doc).toContain('### iframe "pay" — url: https://pay.test/form');
74
+ expect(doc).toContain('- textbox "Kortnummer"');
75
+ // An unnamed frame is still worth showing; it just has no name to print.
76
+ expect(doc).toContain("### iframe — url: about:srcdoc");
77
+ });
78
+
79
+ test("a page with no url or title still renders its tree", () => {
80
+ const doc = formatPageStructure({ ...base, url: "", title: "" });
81
+ expect(doc).toContain("## browser session (default)\n");
82
+ expect(doc).toContain('- heading "Fakturor" [level=1]');
83
+ });
84
+ });
85
+
86
+ describe("truncate", () => {
87
+ test("leaves an ordinary document alone", () => {
88
+ const doc = "- button \"OK\"\n";
89
+ expect(truncate(doc)).toBe(doc);
90
+ });
91
+
92
+ test("cuts on a line boundary and says that it did", () => {
93
+ const doc = `${"- row \"x\"\n".repeat(40_000)}`;
94
+ const out = truncate(doc);
95
+ expect(Buffer.byteLength(out)).toBeLessThanOrEqual(MAX_DOCUMENT_BYTES + 120);
96
+ expect(out.endsWith('- row "x"\n# … truncated at 262144 bytes (400000 bytes captured)\n')).toBe(
97
+ true,
98
+ );
99
+ });
100
+ });
@@ -0,0 +1,180 @@
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
+
38
+ import type { Frame, Page } from "playwright-core";
39
+
40
+ /** One child frame's tree. The main frame's tree leads the document. */
41
+ export interface FrameStructure {
42
+ /** The frame's `name` attribute, or `""` when it has none. */
43
+ name: string;
44
+ url: string;
45
+ tree: string;
46
+ }
47
+
48
+ /** Everything the document is rendered from. Split out from the capture so the
49
+ * formatting is testable without a browser. */
50
+ export interface PageStructure {
51
+ /** The session's name — `ctx.browser("alice")` — or `""` for the default. */
52
+ session: string;
53
+ kind: "browser" | "mobile";
54
+ /** The step that failed (`click`, `goto`, `toBeVisible`, …). */
55
+ action: string;
56
+ /** Its failure message; only the first line is kept. */
57
+ error: string;
58
+ url: string;
59
+ title: string;
60
+ tree: string;
61
+ frames: FrameStructure[];
62
+ }
63
+
64
+ /** The document is a debugging aid, and a pathological page must not grow the
65
+ * `/run` reply (the vm-agent caps a proxied daemon response at 16 MB). Real
66
+ * pages are far below this: the biggest thing measured while building it was
67
+ * 38 KB. */
68
+ export const MAX_DOCUMENT_BYTES = 256 * 1024;
69
+
70
+ /** How many child frames are worth walking. An ad-heavy page can carry
71
+ * dozens; the ones a test drives are at the front. */
72
+ const MAX_FRAMES = 10;
73
+
74
+ /** Per-call deadline. The page has just failed a step, and it may be wedged; a
75
+ * diagnostic must not add to the damage. */
76
+ const CAPTURE_TIMEOUT_MS = 3_000;
77
+
78
+ function withTimeout<T>(work: Promise<T>, fallback: T): Promise<T> {
79
+ let timer: ReturnType<typeof setTimeout> | undefined;
80
+ return Promise.race([
81
+ work.catch(() => fallback),
82
+ new Promise<T>((resolve) => {
83
+ timer = setTimeout(() => resolve(fallback), CAPTURE_TIMEOUT_MS);
84
+ }),
85
+ ]).finally(() => {
86
+ if (timer) clearTimeout(timer);
87
+ });
88
+ }
89
+
90
+ async function frameStructure(frame: Frame): Promise<FrameStructure | undefined> {
91
+ // A frame's tree comes from its own body: `page.ariaSnapshot()` stops at
92
+ // `- iframe` and never descends into it.
93
+ const tree = await withTimeout(frame.locator("body").ariaSnapshot(), "");
94
+ if (!tree.trim()) return undefined;
95
+ return { name: frame.name(), url: frame.url(), tree };
96
+ }
97
+
98
+ /**
99
+ * Capture the page behind a failed step, or `undefined` when there is nothing
100
+ * to show.
101
+ *
102
+ * Everything here is best-effort: a page that is closed, navigating or wedged
103
+ * yields a partial record rather than an error, because the caller is already
104
+ * reporting a failure and must not report this one instead.
105
+ */
106
+ export async function capturePageStructure(
107
+ page: Page,
108
+ meta: Pick<PageStructure, "session" | "kind" | "action" | "error">,
109
+ ): Promise<string | undefined> {
110
+ const tree = await withTimeout(page.ariaSnapshot(), "");
111
+ const title = await withTimeout(page.title(), "");
112
+ let url = "";
113
+ try {
114
+ url = page.url();
115
+ } catch {
116
+ /* The page is gone; the tree, if we got one, is still worth keeping. */
117
+ }
118
+ const frames: FrameStructure[] = [];
119
+ try {
120
+ const children = page.frames().filter((f) => f !== page.mainFrame());
121
+ for (const f of children.slice(0, MAX_FRAMES)) {
122
+ const s = await frameStructure(f);
123
+ if (s) frames.push(s);
124
+ }
125
+ } catch {
126
+ /* Frames are a bonus; the main tree is the point. */
127
+ }
128
+ if (!tree.trim() && frames.length === 0) return undefined;
129
+ return formatPageStructure({ ...meta, url, title, tree, frames });
130
+ }
131
+
132
+ /** The header that tells a reader what they are looking at. Worth its lines:
133
+ * the file is read by someone who did not choose its format, hours later,
134
+ * with nothing around it. */
135
+ const LEGEND = [
136
+ "# Page structure at the moment this step failed.",
137
+ "#",
138
+ '# One line per accessibility node: `role "accessible name" [state]: own text`,',
139
+ "# children indented, properties as `/`-prefixed children (/url, /placeholder).",
140
+ "# The names here are exactly what `getByRole(role, { name })` matches on.",
141
+ "# Elements hidden from assistive technology are absent, as they are from locators.",
142
+ ];
143
+
144
+ function quote(s: string): string {
145
+ return JSON.stringify(s);
146
+ }
147
+
148
+ function sessionHeading(s: PageStructure): string {
149
+ const name = s.session === "" ? "(default)" : quote(s.session);
150
+ const parts = [`## ${s.kind} session ${name}`];
151
+ if (s.url) parts.push(`url: ${s.url}`);
152
+ if (s.title) parts.push(`title: ${quote(s.title)}`);
153
+ return parts.join(" — ");
154
+ }
155
+
156
+ /** Render the document. Pure, so its shape is testable without a browser. */
157
+ export function formatPageStructure(s: PageStructure): string {
158
+ const lines = [...LEGEND, "#", `# failed step: ${s.action}`];
159
+ if (s.error) {
160
+ // The first line only: the near-miss block under a locator failure is
161
+ // already in the CLI output, and this file exists to add to it.
162
+ lines.push(`# failure: ${s.error.split("\n")[0]}`);
163
+ }
164
+ lines.push("", sessionHeading(s), s.tree.trimEnd());
165
+ for (const f of s.frames) {
166
+ const name = f.name === "" ? "" : ` ${quote(f.name)}`;
167
+ lines.push("", `### iframe${name} — url: ${f.url}`, f.tree.trimEnd());
168
+ }
169
+ return truncate(`${lines.join("\n")}\n`);
170
+ }
171
+
172
+ /** Cut the document to {@link MAX_DOCUMENT_BYTES}, on a line boundary, and say
173
+ * so — a silently short tree reads as a page that ends there. */
174
+ export function truncate(doc: string): string {
175
+ const bytes = Buffer.byteLength(doc, "utf8");
176
+ if (bytes <= MAX_DOCUMENT_BYTES) return doc;
177
+ const text = Buffer.from(doc, "utf8").subarray(0, MAX_DOCUMENT_BYTES).toString("utf8");
178
+ const cut = text.slice(0, text.lastIndexOf("\n") + 1);
179
+ return `${cut}# … truncated at ${MAX_DOCUMENT_BYTES} bytes (${bytes} bytes captured)\n`;
180
+ }
package/src/recorder.ts CHANGED
@@ -488,6 +488,22 @@ export interface BrowserEvent extends BaseEvent {
488
488
  attempts?: number;
489
489
  durationMs: number;
490
490
  error?: string;
491
+ /**
492
+ * The page's accessibility tree at the moment this step failed — set only
493
+ * on a failed browser/mobile step (see `page-snapshot.ts`). It rides the
494
+ * event because that is the only channel out of the harness the SDK owns
495
+ * end to end, and because `ctx.poll` truncates a superseded attempt's
496
+ * events, which drops its capture with it.
497
+ *
498
+ * **The control plane lifts this out**: it stores the text as a
499
+ * `case-page` artifact and replaces the field with `pageArtifactId`,
500
+ * exactly as it does for an eval screenshot's inline bytes. So a
501
+ * persisted event carries the id, never the document.
502
+ */
503
+ pageStructure?: string;
504
+ /** The `art_0…` the control plane stored {@link pageStructure} as. Never
505
+ * set by the SDK. */
506
+ pageArtifactId?: string;
491
507
  /**
492
508
  * Session this op belonged to. Set whenever the Browser was opened
493
509
  * with a `BrowserSessionRecorder` attached (the daemon always does).
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ containsText,
5
+ containsTextArray,
6
+ escapeInvisible,
7
+ matchesText,
8
+ matchesTextArray,
9
+ normalizeWhiteSpace,
10
+ textDifferenceNote,
11
+ } from "./text-match.js";
12
+
13
+ // Every invisible character in this file is written as an escape on purpose:
14
+ // a literal one is unreadable in a diff, and a test whose subject cannot be
15
+ // seen in the source is a test nobody can maintain.
16
+
17
+ /** What `Intl.NumberFormat("sv-SE")` produces: a NO-BREAK SPACE between the
18
+ * thousands. It prints exactly like the space an author types, which is the
19
+ * whole reason this module exists. */
20
+ const SEK = "15\u00a0000 kr";
21
+
22
+ describe("normalizeWhiteSpace", () => {
23
+ test("folds every space character onto a plain space", () => {
24
+ expect(normalizeWhiteSpace(SEK)).toBe("15 000 kr");
25
+ expect(normalizeWhiteSpace("15 000 kr")).toBe("15 000 kr");
26
+ expect(normalizeWhiteSpace(" a \n\t b ")).toBe("a b");
27
+ });
28
+
29
+ test("drops the characters that print as nothing", () => {
30
+ expect(normalizeWhiteSpace("a\u200bb\u00adc")).toBe("abc");
31
+ });
32
+ });
33
+
34
+ describe("matchesText", () => {
35
+ test("a typed space matches a no-break space", () => {
36
+ expect(matchesText(SEK, "15 000 kr")).toBe(true);
37
+ });
38
+
39
+ test("markup that wraps the value over two lines still matches", () => {
40
+ expect(matchesText("\n Total:\n 15 000 kr\n", "Total: 15 000 kr")).toBe(true);
41
+ });
42
+
43
+ test("is a whole-string comparison", () => {
44
+ expect(matchesText(SEK, "15 000")).toBe(false);
45
+ expect(containsText(SEK, "15 000")).toBe(true);
46
+ });
47
+
48
+ test("a RegExp is tested against the normalized text", () => {
49
+ expect(matchesText(SEK, /^15 000 kr$/)).toBe(true);
50
+ // The same pattern against the raw text would have to spell U+00A0 out.
51
+ expect(/^15 000 kr$/.test(SEK)).toBe(false);
52
+ });
53
+
54
+ test("ignoreCase covers both a string and a RegExp", () => {
55
+ expect(matchesText("SPARA", "spara")).toBe(false);
56
+ expect(matchesText("SPARA", "spara", { ignoreCase: true })).toBe(true);
57
+ expect(matchesText("SPARA", /spara/, { ignoreCase: true })).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("containsText", () => {
62
+ test("normalizes both sides", () => {
63
+ expect(containsText(`Summa: ${SEK} inkl. moms`, "15 000 kr")).toBe(true);
64
+ });
65
+
66
+ test("takes a RegExp, which the string-only signature could not", () => {
67
+ expect(containsText(SEK, /\d{2} \d{3}/)).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe("array forms", () => {
72
+ const rows = ["Item one", "Item two", "Item three"];
73
+
74
+ test("toHaveText's array is position for position, and the counts must agree", () => {
75
+ expect(matchesTextArray(rows, ["Item one", "Item two", "Item three"])).toBe(true);
76
+ expect(matchesTextArray(rows, ["Item one", "Item three"])).toBe(false);
77
+ expect(matchesTextArray(rows, ["Item one", /two/, "Item three"])).toBe(true);
78
+ });
79
+
80
+ test("toContainText's array allows elements in between, but not out of order", () => {
81
+ expect(containsTextArray(rows, ["one", "three"])).toBe(true);
82
+ expect(containsTextArray(rows, ["three", "one"])).toBe(false);
83
+ expect(containsTextArray(rows, ["four"])).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("escapeInvisible", () => {
88
+ test("spells out the characters that print as nothing", () => {
89
+ expect(escapeInvisible(JSON.stringify(SEK))).toBe('"15\\u00a0000 kr"');
90
+ expect(escapeInvisible(JSON.stringify("a\u200bb"))).toBe('"a\\u200bb"');
91
+ });
92
+
93
+ test("leaves ordinary text, and JSON's own escapes, alone", () => {
94
+ expect(escapeInvisible(JSON.stringify("Spara ändringar"))).toBe('"Spara ändringar"');
95
+ expect(escapeInvisible(JSON.stringify("a\nb"))).toBe('"a\\nb"');
96
+ });
97
+ });
98
+
99
+ describe("textDifferenceNote", () => {
100
+ test("names the invisible character when that is the whole difference", () => {
101
+ const note = textDifferenceNote(SEK, "15 000 kr", "equal");
102
+ expect(note).toContain("they differ only in invisible or look-alike characters");
103
+ expect(note).toContain("NO-BREAK SPACE at index 2");
104
+ });
105
+
106
+ test("covers a substring comparison too", () => {
107
+ const note = textDifferenceNote(`Summa: ${SEK}`, "15 000 kr", "contains");
108
+ expect(note).toContain("NO-BREAK SPACE");
109
+ });
110
+
111
+ test("names a look-alike that is not invisible at all", () => {
112
+ const note = textDifferenceNote("Fri–Sun", "Fri-Sun", "equal");
113
+ expect(note).toContain("EN DASH");
114
+ });
115
+
116
+ test("reports the expected side when the actual is the plain one", () => {
117
+ const note = textDifferenceNote("15 000 kr", SEK, "equal");
118
+ expect(note).toContain("the expected string has NO-BREAK SPACE");
119
+ });
120
+
121
+ test("says so when only the case differs", () => {
122
+ expect(textDifferenceNote("Spara", "spara", "equal")).toBe(
123
+ " — they differ only in letter case",
124
+ );
125
+ });
126
+
127
+ test("stays quiet for a difference the reader can see", () => {
128
+ expect(textDifferenceNote("Spara", "Avbryt", "equal")).toBe("");
129
+ expect(textDifferenceNote("15 000 kr", "15 000 kr", "equal")).toBe("");
130
+ expect(textDifferenceNote(42, "42", "equal")).toBe("");
131
+ });
132
+ });