@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/dist/index.js CHANGED
@@ -39,7 +39,8 @@ export { S3Client } from "./s3.js";
39
39
  // own browser. See `mcp.ts`.
40
40
  export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
41
41
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
42
- import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
42
+ import { classifyLocatorFailure, formatWaited, locatorFailureMessage, } from "./locator-errors.js";
43
+ import { containsText, containsTextArray, escapeInvisible, matchesText, matchesTextArray, textDifferenceNote, } from "./text-match.js";
43
44
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
44
45
  import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
45
46
  import { resolveExistingProjectPath } from "./project-files.js";
@@ -674,14 +675,33 @@ function buildLocatorMatchers(loc, negated, message) {
674
675
  // matches a hidden element fail identically — so the count is read once,
675
676
  // at failure time only. `toHaveCount` is left alone: "expected count 2,
676
677
  // got 0" already says it, and better.
678
+ //
679
+ // Either way the message ends with the near-miss block — what the page
680
+ // holds that is close to what was asked for (see locator-hints.ts) — so
681
+ // the author does not have to run again to find out.
682
+ const nearMiss = async () => {
683
+ try {
684
+ return await probe.nearMiss();
685
+ }
686
+ catch {
687
+ return "";
688
+ }
689
+ };
677
690
  const elementFailure = async (err) => {
678
- if (err !== undefined)
679
- return locatorFailureMessage(probe.label, err);
691
+ if (err !== undefined) {
692
+ const failure = classifyLocatorFailure(probe.label, err);
693
+ if (!failure)
694
+ return undefined;
695
+ return failure.kind === "no-match"
696
+ ? failure.message + (await nearMiss())
697
+ : failure.message;
698
+ }
680
699
  if (expectsGone || matcher === "toHaveCount")
681
700
  return undefined;
682
701
  try {
683
702
  if ((await probe.count()) === 0) {
684
- return `No element matches ${probe.label} (waited ${formatWaited(budget)})`;
703
+ return (`No element matches ${probe.label} (waited ${formatWaited(budget)})` +
704
+ (await nearMiss()));
685
705
  }
686
706
  }
687
707
  catch {
@@ -754,7 +774,22 @@ function buildLocatorMatchers(loc, negated, message) {
754
774
  }
755
775
  };
756
776
  const not = negated ? " not" : "";
757
- const matchesText = (v, expected) => expected instanceof RegExp ? expected.test(v) : v === expected;
777
+ // The element's text, read the way the caller asked for it. `useInnerText`
778
+ // is playwright's option and means the same here: what the page renders,
779
+ // rather than every character in the subtree.
780
+ const readText = async (opts) => opts?.useInnerText
781
+ ? await probe.innerText(opts.timeout)
782
+ : ((await probe.textContent(opts?.timeout)) ?? "");
783
+ const readTexts = async (opts) => opts?.useInnerText ? await probe.allInnerTexts() : await probe.allTextContents();
784
+ const isArrayExpectation = (v) => Array.isArray(v);
785
+ /** Expected values render as themselves; a RegExp renders as its source. */
786
+ const fmtExpected = (v) => v instanceof RegExp
787
+ ? String(v)
788
+ : Array.isArray(v)
789
+ ? `[${v.map(fmtExpected).join(", ")}]`
790
+ : fmt(v);
791
+ /** What goes on the assertion event: a RegExp cannot be serialized. */
792
+ const expectedValue = (v) => (v instanceof RegExp ? String(v) : Array.isArray(v) ? v.map(expectedValue) : v);
758
793
  return {
759
794
  toBeVisible: (opts) => run("toBeVisible", opts?.timeout, async () => {
760
795
  const v = await probe.isVisible();
@@ -765,17 +800,34 @@ function buildLocatorMatchers(loc, negated, message) {
765
800
  return { satisfied: !v, actual: v };
766
801
  }, () => `expected ${probe.label}${not} to be hidden`),
767
802
  toHaveText: (expected, opts) => run("toHaveText", opts?.timeout, async () => {
768
- const t = (await probe.textContent(opts?.timeout)) ?? "";
769
- return { satisfied: matchesText(t.trim(), expected), actual: t };
770
- }, (a) => `expected ${probe.label}${not} to have text ${fmt(expected)}, got ${fmt(a)}`, expected instanceof RegExp ? String(expected) : expected),
803
+ if (isArrayExpectation(expected)) {
804
+ const texts = await readTexts(opts);
805
+ return { satisfied: matchesTextArray(texts, expected, opts), actual: texts };
806
+ }
807
+ const t = await readText(opts);
808
+ return { satisfied: matchesText(t, expected, opts), actual: t };
809
+ }, (a) => `expected ${probe.label}${not} to have text ${fmtExpected(expected)}, got ${fmt(a)}` +
810
+ textDifferenceNote(a, expected, "equal"), expectedValue(expected)),
771
811
  toContainText: (expected, opts) => run("toContainText", opts?.timeout, async () => {
772
- const t = (await probe.textContent(opts?.timeout)) ?? "";
773
- return { satisfied: t.includes(expected), actual: t };
774
- }, (a) => `expected ${probe.label}${not} to contain text ${fmt(expected)}, got ${fmt(a)}`, expected),
812
+ if (isArrayExpectation(expected)) {
813
+ const texts = await readTexts(opts);
814
+ return { satisfied: containsTextArray(texts, expected, opts), actual: texts };
815
+ }
816
+ const t = await readText(opts);
817
+ return { satisfied: containsText(t, expected, opts), actual: t };
818
+ }, (a) => `expected ${probe.label}${not} to contain text ${fmtExpected(expected)}, got ${fmt(a)}` +
819
+ textDifferenceNote(a, expected, "contains"), expectedValue(expected)),
820
+ // Deliberately NOT whitespace-normalized: an input's value is data the
821
+ // user typed or the app set, not rendered text, and playwright compares
822
+ // it exactly for the same reason. When that exactness is what failed,
823
+ // `textDifferenceNote` says so instead of leaving two identical-looking
824
+ // strings on screen.
775
825
  toHaveValue: (expected, opts) => run("toHaveValue", opts?.timeout, async () => {
776
826
  const v = await probe.inputValue(opts?.timeout);
777
- return { satisfied: matchesText(v, expected), actual: v };
778
- }, (a) => `expected ${probe.label}${not} to have value ${fmt(expected)}, got ${fmt(a)}`, expected instanceof RegExp ? String(expected) : expected),
827
+ const satisfied = expected instanceof RegExp ? expected.test(v) : v === expected;
828
+ return { satisfied, actual: v };
829
+ }, (a) => `expected ${probe.label}${not} to have value ${fmtExpected(expected)}, got ${fmt(a)}` +
830
+ textDifferenceNote(a, expected, "equal"), expectedValue(expected)),
779
831
  toHaveCount: (expected, opts) => run("toHaveCount", opts?.timeout, async () => {
780
832
  const c = await probe.count();
781
833
  return { satisfied: c === expected, actual: c };
@@ -949,7 +1001,8 @@ function buildCore(actual, tag, negated, message, pendingError) {
949
1001
  return {
950
1002
  toBe(expected) {
951
1003
  const exp = readRaw(expected);
952
- run("toBe", Object.is(actual, exp), `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}`, exp);
1004
+ run("toBe", Object.is(actual, exp), `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}` +
1005
+ textDifferenceNote(actual, exp, "equal"), exp);
953
1006
  },
954
1007
  toEqual(expected) {
955
1008
  const exp = readRaw(expected);
@@ -960,7 +1013,8 @@ function buildCore(actual, tag, negated, message, pendingError) {
960
1013
  catch {
961
1014
  equal = false;
962
1015
  }
963
- run("toEqual", equal, `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}`, exp);
1016
+ run("toEqual", equal, `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}` +
1017
+ textDifferenceNote(actual, exp, "equal"), exp);
964
1018
  },
965
1019
  toBeTruthy() {
966
1020
  run("toBeTruthy", !!actual, `expected ${fmt(actual)}${negated ? " not" : ""} to be truthy`);
@@ -997,7 +1051,8 @@ function buildCore(actual, tag, negated, message, pendingError) {
997
1051
  }
998
1052
  });
999
1053
  }
1000
- run("toContain", contained, `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}`, exp);
1054
+ run("toContain", contained, `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}` +
1055
+ textDifferenceNote(actual, exp, "contains"), exp);
1001
1056
  },
1002
1057
  toMatch(re) {
1003
1058
  run("toMatch", typeof actual === "string" && re.test(actual), `expected ${fmt(actual)}${negated ? " not" : ""} to match ${re}`, String(re));
@@ -1032,8 +1087,12 @@ function buildCore(actual, tag, negated, message, pendingError) {
1032
1087
  };
1033
1088
  }
1034
1089
  function fmt(v) {
1090
+ // A string is escaped down to its invisible characters, so two values that
1091
+ // print the same on screen do not print the same in a failure. Without it a
1092
+ // no-break space and a space are the same three characters wide, and the
1093
+ // message reads "expected "15 000 kr", got "15 000 kr"".
1035
1094
  if (typeof v === "string")
1036
- return JSON.stringify(v);
1095
+ return escapeInvisible(JSON.stringify(v));
1037
1096
  if (typeof v === "bigint")
1038
1097
  return `${v}n`;
1039
1098
  if (v === undefined)
@@ -1,21 +1,30 @@
1
1
  /** A wait's duration, in the units a reader thinks in: "800ms", "5s", "1.5s". */
2
2
  export declare function formatWaited(ms: number): string;
3
+ /** How a locator failed. `no-match` is the only kind worth asking the page
4
+ * about (see locator-hints.ts) — for every other kind the element was found
5
+ * and near misses would be noise. */
6
+ export type LocatorFailureKind = "no-match" | "state" | "strict";
7
+ export interface LocatorFailure {
8
+ message: string;
9
+ kind: LocatorFailureKind;
10
+ }
3
11
  /**
4
- * The sentence to fail with, or `undefined` when `err` is not a locator
5
- * timeout we understand (in which case the caller must leave it alone).
12
+ * What to fail with, or `undefined` when `err` is not a locator failure we
13
+ * understand (in which case the caller must leave it alone).
6
14
  *
7
15
  * `label` is the locator's human chain label, the same string the timeline
8
16
  * step shows.
9
17
  */
18
+ export declare function classifyLocatorFailure(label: string, err: unknown): LocatorFailure | undefined;
19
+ /** The sentence alone. See {@link classifyLocatorFailure}. */
10
20
  export declare function locatorFailureMessage(label: string, err: unknown): string | undefined;
11
21
  /**
12
- * Replace a locator timeout's message in place and hand the error back, so the
13
- * caller can `throw` it unchanged in every other respect.
22
+ * The rewrite above, plus the near-miss block for the one failure that can
23
+ * carry one — a locator that matched nothing.
14
24
  *
15
- * The error is mutated rather than wrapped: its stack holds the frames of the
16
- * author's own call, which a fresh Error would lose. The stack string embeds
17
- * the old message (it is built at construction), so that copy is rewritten
18
- * too — otherwise the CLI's failure block would print the friendly message and
19
- * then the timeout wall right under it.
25
+ * `hints` is only called in that case, so a page query is never made for a
26
+ * failure it could not explain (a disabled element, a covered one, an
27
+ * ambiguous match). It is expected to be best-effort itself; anything it
28
+ * throws leaves the plain sentence standing.
20
29
  */
21
- export declare function rewriteLocatorError(err: unknown, label: string): unknown;
30
+ export declare function rewriteLocatorErrorWithHints(err: unknown, label: string, hints: () => Promise<string>): Promise<unknown>;
@@ -8,10 +8,15 @@
8
8
  // as an infrastructure problem and says nothing about the page.
9
9
  //
10
10
  // This module turns those into one sentence that names the element and what
11
- // was wrong with it. Anything it does not recognise (a strict-mode violation,
12
- // a closed page, an assertion of ours) is passed through untouched — the rule
13
- // is that a message is only ever replaced when we have something better to
14
- // say.
11
+ // was wrong with it. A strict-mode violation — the opposite failure, where the
12
+ // locator matched too much — is rewritten too, keeping playwright's own list
13
+ // of matches, which is the useful half of it. Anything else (a closed page, an
14
+ // assertion of ours) is passed through untouched: the rule is that a message is
15
+ // only ever replaced when we have something better to say.
16
+ //
17
+ // `classifyLocatorFailure` also says WHICH failure it was, because only one of
18
+ // them — a locator that matched nothing — can be explained further by asking
19
+ // the page what it holds (see locator-hints.ts).
15
20
  //
16
21
  // The call log is read from the error's own `log` array (playwright attaches
17
22
  // it) and falls back to parsing the message, since only the message survives
@@ -80,38 +85,72 @@ function actionabilityReason(log) {
80
85
  }
81
86
  return undefined;
82
87
  }
88
+ /** Playwright's strict-mode violation: the locator matched more than one
89
+ * element and refused to act. It is an ordinary `Error`, not a
90
+ * `TimeoutError`, and its message already lists the matches — which is the
91
+ * useful half, so it is kept and only the wall of text around it is cut. */
92
+ function parseStrictViolation(err, label) {
93
+ const e = err;
94
+ if (!e || typeof e.message !== "string")
95
+ return undefined;
96
+ if (!e.message.includes("strict mode violation"))
97
+ return undefined;
98
+ const m = /resolved to (\d+) elements/.exec(e.message);
99
+ if (!m)
100
+ return undefined;
101
+ const matches = e.message
102
+ .split("\n")
103
+ .filter((l) => /^\s*\d+\)\s/.test(l))
104
+ .slice(0, 3)
105
+ .map((l) => ` - ${l.trim().replace(/^\d+\)\s*/, "")}`);
106
+ const head = `${label} matches ${m[1]} elements, so it is ambiguous` +
107
+ " — narrow it with .first(), .nth(i), .filter({ hasText }) or a more specific query";
108
+ return {
109
+ kind: "strict",
110
+ message: matches.length ? `${head}:\n${matches.join("\n")}` : head,
111
+ };
112
+ }
83
113
  /**
84
- * The sentence to fail with, or `undefined` when `err` is not a locator
85
- * timeout we understand (in which case the caller must leave it alone).
114
+ * What to fail with, or `undefined` when `err` is not a locator failure we
115
+ * understand (in which case the caller must leave it alone).
86
116
  *
87
117
  * `label` is the locator's human chain label, the same string the timeline
88
118
  * step shows.
89
119
  */
90
- export function locatorFailureMessage(label, err) {
120
+ export function classifyLocatorFailure(label, err) {
121
+ const strict = parseStrictViolation(err, label);
122
+ if (strict)
123
+ return strict;
91
124
  const detail = parseTimeout(err);
92
125
  if (!detail)
93
126
  return undefined;
94
127
  const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
95
- if (!resolved(detail.log))
96
- return `No element matches ${label} ${waited}`;
128
+ if (!resolved(detail.log)) {
129
+ return { kind: "no-match", message: `No element matches ${label} ${waited}` };
130
+ }
97
131
  // It matched, so the failure is about the element's state.
132
+ const state = (message) => ({ kind: "state", message });
98
133
  switch (waitedForState(detail.log)) {
99
134
  case "hidden":
100
- return `Element ${label} is still visible ${waited}`;
135
+ return state(`Element ${label} is still visible ${waited}`);
101
136
  case "detached":
102
- return `Element ${label} is still attached to the page ${waited}`;
137
+ return state(`Element ${label} is still attached to the page ${waited}`);
103
138
  case "visible":
104
- return `Element ${label} is not visible ${waited}`;
139
+ return state(`Element ${label} is not visible ${waited}`);
105
140
  default:
106
141
  break;
107
142
  }
108
143
  const reason = actionabilityReason(detail.log);
109
- return reason
144
+ return state(reason
110
145
  ? `Element ${label} ${reason} ${waited}`
111
- : `Element ${label} never became ready for this action ${waited}`;
146
+ : `Element ${label} never became ready for this action ${waited}`);
147
+ }
148
+ /** The sentence alone. See {@link classifyLocatorFailure}. */
149
+ export function locatorFailureMessage(label, err) {
150
+ return classifyLocatorFailure(label, err)?.message;
112
151
  }
113
152
  /**
114
- * Replace a locator timeout's message in place and hand the error back, so the
153
+ * Replace a locator failure's message in place and hand the error back, so the
115
154
  * caller can `throw` it unchanged in every other respect.
116
155
  *
117
156
  * The error is mutated rather than wrapped: its stack holds the frames of the
@@ -120,11 +159,8 @@ export function locatorFailureMessage(label, err) {
120
159
  * too — otherwise the CLI's failure block would print the friendly message and
121
160
  * then the timeout wall right under it.
122
161
  */
123
- export function rewriteLocatorError(err, label) {
124
- const message = locatorFailureMessage(label, err);
125
- if (message === undefined)
126
- return err;
127
- const e = err;
162
+ /** Put `message` on `err`, in the message and in the stack's copy of it. */
163
+ function setErrorMessage(e, message) {
128
164
  const old = e.message;
129
165
  if (typeof e.stack === "string") {
130
166
  for (const [header, replacement] of [
@@ -140,3 +176,27 @@ export function rewriteLocatorError(err, label) {
140
176
  e.message = message;
141
177
  return e;
142
178
  }
179
+ /**
180
+ * The rewrite above, plus the near-miss block for the one failure that can
181
+ * carry one — a locator that matched nothing.
182
+ *
183
+ * `hints` is only called in that case, so a page query is never made for a
184
+ * failure it could not explain (a disabled element, a covered one, an
185
+ * ambiguous match). It is expected to be best-effort itself; anything it
186
+ * throws leaves the plain sentence standing.
187
+ */
188
+ export async function rewriteLocatorErrorWithHints(err, label, hints) {
189
+ const failure = classifyLocatorFailure(label, err);
190
+ if (!failure)
191
+ return err;
192
+ let message = failure.message;
193
+ if (failure.kind === "no-match") {
194
+ try {
195
+ message += await hints();
196
+ }
197
+ catch {
198
+ /* A diagnostic must never replace the failure it explains. */
199
+ }
200
+ }
201
+ return setErrorMessage(err, message);
202
+ }
@@ -0,0 +1,96 @@
1
+ import type { Page } from "playwright-core";
2
+ /** What the author asked for — the last selecting step of a failed chain. */
3
+ export interface HintTarget {
4
+ kind: "role" | "text" | "label" | "placeholder" | "altText" | "title" | "testId" | "other";
5
+ /** The ARIA role, for `kind: "role"`. */
6
+ role?: string;
7
+ /** The string the author asked for. Absent for a RegExp query, which we
8
+ * make no suggestions from — a near-miss on a pattern is not a near-miss. */
9
+ query?: string;
10
+ }
11
+ /** One role/name pair from the page's accessibility tree. */
12
+ export interface AriaCandidate {
13
+ role: string;
14
+ name: string;
15
+ }
16
+ /** An interactive element whose visible text is not its accessible name. */
17
+ export interface NameMismatch {
18
+ tag: string;
19
+ text: string;
20
+ name: string;
21
+ /** The attribute the name came from: `aria-label`, `aria-labelledby`, … */
22
+ from: string;
23
+ }
24
+ /** What the DOM pass brings back. Every list is deduplicated and capped. */
25
+ export interface DomFacts {
26
+ texts: string[];
27
+ testIds: string[];
28
+ placeholders: string[];
29
+ labels: string[];
30
+ titles: string[];
31
+ alts: string[];
32
+ mismatches: NameMismatch[];
33
+ }
34
+ export declare const EMPTY_DOM_FACTS: DomFacts;
35
+ /** The locator a suggestion proposes, as data — so the ranking stays pure and
36
+ * testable and only `nearMissHints` touches a page. */
37
+ export type SuggestedLocator = {
38
+ m: "getByRole";
39
+ role: string;
40
+ name: string;
41
+ } | {
42
+ m: "getByText";
43
+ text: string;
44
+ } | {
45
+ m: "getByLabel";
46
+ text: string;
47
+ } | {
48
+ m: "getByPlaceholder";
49
+ text: string;
50
+ } | {
51
+ m: "getByAltText";
52
+ text: string;
53
+ } | {
54
+ m: "getByTitle";
55
+ text: string;
56
+ } | {
57
+ m: "getByTestId";
58
+ id: string;
59
+ };
60
+ export interface Suggestion {
61
+ /** What is on the page, in the reader's terms. */
62
+ fact: string;
63
+ /** The locator that selects it. */
64
+ locator: SuggestedLocator;
65
+ /** Higher is closer. Ranking only; never shown. */
66
+ score: number;
67
+ }
68
+ /**
69
+ * How close `candidate` is to what the author asked for, from 0 (unrelated)
70
+ * to 4 (the same string). Everything at or above `NEAR` is worth showing.
71
+ */
72
+ export declare function closeness(query: string, candidate: string): number;
73
+ /** Render one verified suggestion as the line the author reads. */
74
+ export declare function suggestionLine(s: Suggestion, matches: number): string;
75
+ /**
76
+ * Rank what the page holds against what the author asked for. Pure: the
77
+ * caller collects `aria`/`dom` and verifies the winners.
78
+ */
79
+ export declare function buildSuggestions(target: HintTarget, aria: AriaCandidate[], dom: DomFacts): Suggestion[];
80
+ /**
81
+ * Pull `- button "Save"` lines out of an ARIA snapshot.
82
+ *
83
+ * The snapshot is YAML, but only its leading token carries what we need, so
84
+ * this reads it line by line rather than pulling in a parser. A line we do not
85
+ * recognise is skipped — the worst case is one fewer suggestion.
86
+ */
87
+ export declare function parseAriaSnapshot(yaml: string): AriaCandidate[];
88
+ /**
89
+ * The lines to append to a "no element matches" failure — at most
90
+ * {@link MAX_SUGGESTIONS}, each verified against the live page, and empty
91
+ * whenever the page holds nothing close.
92
+ */
93
+ export declare function nearMissHints(page: Page, target: HintTarget): Promise<string[]>;
94
+ /** The block appended under a failure sentence, or `""` when there is
95
+ * nothing to add. */
96
+ export declare function formatHints(lines: string[]): string;