@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
package/src/locator.ts
CHANGED
|
@@ -26,7 +26,8 @@ import { Buffer } from "node:buffer";
|
|
|
26
26
|
import type { Page, Locator as PWLocator } from "playwright-core";
|
|
27
27
|
import type { RecordableFields } from "./browser.js";
|
|
28
28
|
import type { Wrapped } from "./inspect.js";
|
|
29
|
-
import {
|
|
29
|
+
import { rewriteLocatorErrorWithHints } from "./locator-errors.js";
|
|
30
|
+
import { formatHints, nearMissHints, type HintTarget } from "./locator-hints.js";
|
|
30
31
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
31
32
|
import { truncateUtf8 } from "./recorder.js";
|
|
32
33
|
|
|
@@ -261,6 +262,45 @@ function stepLabel(step: Step): string {
|
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
|
|
265
|
+
/**
|
|
266
|
+
* What a chain asked for, in the terms the near-miss diagnostic reasons in
|
|
267
|
+
* (see locator-hints.ts). `undefined` when this chain must not produce
|
|
268
|
+
* suggestions.
|
|
269
|
+
*
|
|
270
|
+
* **Single-step chains only, deliberately.** A suggestion is verified against
|
|
271
|
+
* the whole page, so for a scoped chain (`locator("#form").getByRole(…)`) a
|
|
272
|
+
* page-wide match could resolve outside the scope — a suggestion that does not
|
|
273
|
+
* work where the author is looking is worse than no suggestion. A RegExp query
|
|
274
|
+
* is skipped for the same reason: a pattern has no near miss to speak of.
|
|
275
|
+
*/
|
|
276
|
+
export function hintTargetFromChain(chain: Chain): HintTarget | undefined {
|
|
277
|
+
if (chain.steps.length !== 1) return undefined;
|
|
278
|
+
const step = chain.steps[0]!;
|
|
279
|
+
const textQuery = (v: string | RegExp): string | undefined =>
|
|
280
|
+
typeof v === "string" ? v : undefined;
|
|
281
|
+
switch (step.m) {
|
|
282
|
+
case "getByRole": {
|
|
283
|
+
const name = step.args[1]?.name;
|
|
284
|
+
if (name === undefined) return undefined; // Only a role: nothing to be near.
|
|
285
|
+
return { kind: "role", role: step.args[0], query: textQuery(name) };
|
|
286
|
+
}
|
|
287
|
+
case "getByText":
|
|
288
|
+
return { kind: "text", query: textQuery(step.args[0]) };
|
|
289
|
+
case "getByLabel":
|
|
290
|
+
return { kind: "label", query: textQuery(step.args[0]) };
|
|
291
|
+
case "getByPlaceholder":
|
|
292
|
+
return { kind: "placeholder", query: textQuery(step.args[0]) };
|
|
293
|
+
case "getByAltText":
|
|
294
|
+
return { kind: "altText", query: textQuery(step.args[0]) };
|
|
295
|
+
case "getByTitle":
|
|
296
|
+
return { kind: "title", query: textQuery(step.args[0]) };
|
|
297
|
+
case "getByTestId":
|
|
298
|
+
return { kind: "testId", query: step.args[0] };
|
|
299
|
+
default:
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
264
304
|
/** Short human label for a chain, e.g.
|
|
265
305
|
* `role button "15" ‹ filter(has: text "July 2026") ‹ css .calendar`.
|
|
266
306
|
* Root first; narrowing steps appended in reading order. */
|
|
@@ -315,8 +355,17 @@ export interface LocatorProbe {
|
|
|
315
355
|
readonly label: string;
|
|
316
356
|
isVisible(): Promise<boolean>;
|
|
317
357
|
textContent(timeout?: number): Promise<string | null>;
|
|
358
|
+
innerText(timeout?: number): Promise<string>;
|
|
359
|
+
/** Every match's text — the array forms of `toHaveText`/`toContainText`,
|
|
360
|
+
* which are about a list of elements and so must not go through the
|
|
361
|
+
* single-element (strict) reads above. */
|
|
362
|
+
allTextContents(): Promise<string[]>;
|
|
363
|
+
allInnerTexts(): Promise<string[]>;
|
|
318
364
|
inputValue(timeout?: number): Promise<string>;
|
|
319
365
|
count(): Promise<number>;
|
|
366
|
+
/** The "did you mean…" block for a locator that matched nothing, or `""`.
|
|
367
|
+
* See locator-hints.ts. */
|
|
368
|
+
nearMiss(): Promise<string>;
|
|
320
369
|
isEnabled(timeout?: number): Promise<boolean>;
|
|
321
370
|
isChecked(timeout?: number): Promise<boolean>;
|
|
322
371
|
/** After the silent poll settles, emit the single settled browser step this
|
|
@@ -702,16 +751,26 @@ export function makeLocator(
|
|
|
702
751
|
const extend = (step: Step): Locator =>
|
|
703
752
|
makeLocator(backend, strategy, { steps: [...chain.steps, step] });
|
|
704
753
|
|
|
754
|
+
/** The "did you mean…" block for a locator that matched nothing. Empty
|
|
755
|
+
* for a chain that must not suggest (see {@link hintTargetFromChain}) and
|
|
756
|
+
* for a page that holds nothing close. */
|
|
757
|
+
const hintBlock = async (page: Page): Promise<string> => {
|
|
758
|
+
const target = hintTargetFromChain(chain);
|
|
759
|
+
if (!target) return "";
|
|
760
|
+
return formatHints(await nearMissHints(page, target));
|
|
761
|
+
};
|
|
762
|
+
|
|
705
763
|
// Every terminal op runs through this: playwright's actionability timeouts
|
|
706
764
|
// say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
|
|
707
765
|
// log, so they are rewritten into a sentence naming the element and its
|
|
708
|
-
// state (see locator-errors.ts)
|
|
709
|
-
//
|
|
710
|
-
|
|
766
|
+
// state (see locator-errors.ts), and a failure that found NO element asks
|
|
767
|
+
// the page what it does hold. Applied INSIDE `pageOp`, so the recorded step
|
|
768
|
+
// carries the readable message too — not just the thrown error.
|
|
769
|
+
const readable = async <T>(page: Page, fn: () => Promise<T>): Promise<T> => {
|
|
711
770
|
try {
|
|
712
771
|
return await fn();
|
|
713
772
|
} catch (err) {
|
|
714
|
-
throw
|
|
773
|
+
throw await rewriteLocatorErrorWithHints(err, label, () => hintBlock(page));
|
|
715
774
|
}
|
|
716
775
|
};
|
|
717
776
|
|
|
@@ -722,7 +781,7 @@ export function makeLocator(
|
|
|
722
781
|
fn: (loc: PWLocator, page: Page) => Promise<T>,
|
|
723
782
|
): Promise<T> =>
|
|
724
783
|
backend.pageOp(action, { selector: label, ...fields }, (page) =>
|
|
725
|
-
readable(() => fn(lower(page, chain), page)),
|
|
784
|
+
readable(page, () => fn(lower(page, chain), page)),
|
|
726
785
|
);
|
|
727
786
|
|
|
728
787
|
// One recorded event whose fields the action itself finishes filling in:
|
|
@@ -735,7 +794,7 @@ export function makeLocator(
|
|
|
735
794
|
): Promise<T> => {
|
|
736
795
|
const rec: Partial<RecordableFields> = { selector: label };
|
|
737
796
|
return backend.pageOp(action, rec, (page) =>
|
|
738
|
-
readable(() => fn(lower(page, chain), rec)),
|
|
797
|
+
readable(page, () => fn(lower(page, chain), rec)),
|
|
739
798
|
);
|
|
740
799
|
};
|
|
741
800
|
|
|
@@ -748,7 +807,7 @@ export function makeLocator(
|
|
|
748
807
|
backend.pageOp(
|
|
749
808
|
action,
|
|
750
809
|
{ selector: label, ...fields },
|
|
751
|
-
(page) => readable(() => fn(lower(page, chain))),
|
|
810
|
+
(page) => readable(page, () => fn(lower(page, chain))),
|
|
752
811
|
{ wrap: true },
|
|
753
812
|
) as Promise<Wrapped<T>>;
|
|
754
813
|
|
|
@@ -757,8 +816,12 @@ export function makeLocator(
|
|
|
757
816
|
label,
|
|
758
817
|
isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
|
|
759
818
|
textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
|
|
819
|
+
innerText: (timeout) => backend.silentRead((page) => lower(page, chain).innerText({ timeout })),
|
|
820
|
+
allTextContents: () => backend.silentRead((page) => lower(page, chain).allTextContents()),
|
|
821
|
+
allInnerTexts: () => backend.silentRead((page) => lower(page, chain).allInnerTexts()),
|
|
760
822
|
inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
|
|
761
823
|
count: () => backend.silentRead((page) => lower(page, chain).count()),
|
|
824
|
+
nearMiss: () => backend.silentRead((page) => hintBlock(page)),
|
|
762
825
|
isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
|
|
763
826
|
isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
|
|
764
827
|
settle: async (action, waitedMs, error, opts) => {
|
|
@@ -867,7 +930,7 @@ export function makeLocator(
|
|
|
867
930
|
backend.pageOp(
|
|
868
931
|
"evaluate",
|
|
869
932
|
{ selector: label, description },
|
|
870
|
-
(page) => readable(() => lower(page, chain).evaluate(fn as never, arg)),
|
|
933
|
+
(page) => readable(page, () => lower(page, chain).evaluate(fn as never, arg)),
|
|
871
934
|
{ wrap: true },
|
|
872
935
|
) as Promise<Wrapped<never>>,
|
|
873
936
|
|
|
@@ -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
|
+
});
|