@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
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 { rewriteLocatorError } from "./locator-errors.js";
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). Applied INSIDE `pageOp`, so the recorded
709
- // step carries the readable message too — not just the thrown error.
710
- const readable = async <T>(fn: () => Promise<T>): Promise<T> => {
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 rewriteLocatorError(err, label);
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
+ });