@specific.dev/spectest 0.121.0 → 0.123.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/src/locator.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  // author-facing call is exactly one recorded browser event (with its rrweb
23
23
  // drain), whatever playwright work it composes underneath.
24
24
 
25
+ import type { LocatorAssertionRequest } from "./playwright-assertions.js";
25
26
  import { Buffer } from "node:buffer";
26
27
  import type { Page, Locator as PWLocator } from "playwright-core";
27
28
  import type { RecordableFields } from "./browser.js";
@@ -346,7 +347,7 @@ export function chainLabel(chain: Chain): string {
346
347
  /** The slice of the browser backend a locator needs. `pageOp` records one
347
348
  * browser event and (when `wrap`) provenance-wraps the result so a later
348
349
  * `expect(...)` nests under the step. `silentRead` runs a lowered read with
349
- * NO recording/drain — the poll path for `expect(locator)` matchers, which
350
+ * NO recording/drain — the assertion path for `expect(locator)` matchers, which
350
351
  * must not emit one browser event per retry. */
351
352
  export interface LocatorBackend {
352
353
  pageOp<T>(
@@ -369,30 +370,18 @@ export interface LocatorBackend {
369
370
  ): Promise<number | undefined>;
370
371
  }
371
372
 
372
- /** Silent (non-recorded) reads a locator exposes for `expect(...)` matchers to
373
- * poll. See {@link getLocatorProbe}. */
373
+ /** Native assertions and recording for a lazy Spectest locator. */
374
374
  export interface LocatorProbe {
375
- /** Human label of the locator (for failure messages). */
376
375
  readonly label: string;
377
- isVisible(): Promise<boolean>;
378
- textContent(timeout?: number): Promise<string | null>;
379
- innerText(timeout?: number): Promise<string>;
380
- /** Every match's text — the array forms of `toHaveText`/`toContainText`,
381
- * which are about a list of elements and so must not go through the
382
- * single-element (strict) reads above. */
383
- allTextContents(): Promise<string[]>;
384
- allInnerTexts(): Promise<string[]>;
385
- inputValue(timeout?: number): Promise<string>;
386
- count(): Promise<number>;
387
- /** The "did you mean…" block for a locator that matched nothing, or `""`.
388
- * See locator-hints.ts. */
376
+ /** Resolve the current page once per assertion; Playwright re-resolves the
377
+ * selector on that page throughout the assertion, including navigation. */
378
+ assert(request: LocatorAssertionRequest): Promise<void>;
379
+ /** Supplemental diagnostics, never part of deciding the assertion result. */
389
380
  nearMiss(): Promise<string>;
390
- isEnabled(timeout?: number): Promise<boolean>;
391
- isChecked(timeout?: number): Promise<boolean>;
392
- /** After the silent poll settles, emit the single settled browser step this
381
+ /** After the native assertion settles, emit the single settled browser step this
393
382
  * locator's `expect(...)` matcher assertion nests under, and return its seq
394
383
  * (provenance + replay seek). `action` is the matcher name, `waitedMs` the
395
- * poll time, `error` marks the step failed on a timed-out matcher. Returns
384
+ * assertion time, `error` marks the step failed on a failed matcher. Returns
396
385
  * `undefined` when nothing is recording.
397
386
  *
398
387
  * `opts.reveal` additionally stamps the element's rrweb node id for the
@@ -408,14 +397,11 @@ export interface LocatorProbe {
408
397
  ): Promise<number | undefined>;
409
398
  }
410
399
 
411
- /** Silent (non-recorded) reads an `expect(browser)` matcher polls — the
412
- * session twin of {@link LocatorProbe}. Lives here, next to it, so `index.ts`
413
- * can reach both seams without importing `browser.ts` (and with it
414
- * playwright) at runtime. */
400
+ /** Native page assertions, kept behind a seam so index.ts does not load
401
+ * Playwright when a project only imports configuration or value assertions. */
415
402
  export interface BrowserProbe {
416
- /** Current main-frame URL. Synchronous, unrecorded, no rrweb drain. */
417
- url(): string;
418
- /** After the silent poll settles, emit the single settled browser step the
403
+ assertURL(negated: boolean, expected: import("./url-match.js").UrlPattern, opts?: { timeout?: number }): Promise<void>;
404
+ /** After the native assertion settles, emit the single settled browser step the
419
405
  * matcher's assertion nests under, and return its seq. Same contract as
420
406
  * {@link LocatorProbe.settle}. */
421
407
  settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
@@ -440,9 +426,7 @@ export function getBrowserProbe(session: unknown): BrowserProbe {
440
426
  return (session as Record<symbol, BrowserProbe>)[BROWSER_PROBE];
441
427
  }
442
428
 
443
- /** The silent-read probe for a locator — the seam `expect(locator)` matchers
444
- * poll (in index.ts) without pulling playwright types or the backend into
445
- * that module. */
429
+ /** The assertion/recording seam used by index.ts without loading Playwright. */
446
430
  export function getLocatorProbe(loc: Locator): LocatorProbe {
447
431
  return (loc as unknown as Record<symbol, LocatorProbe>)[PROBE];
448
432
  }
@@ -649,9 +633,9 @@ function lowerInputFiles(files: InputFiles): {
649
633
  * A lazy reference to element(s), resolved with auto-waiting at the moment an
650
634
  * action runs. Mirrors playwright-core's `Locator`: STRICT by default — a
651
635
  * locator resolving to multiple elements throws a strict-mode violation at
652
- * action time rather than silently acting on one. Narrow with `exact: true`,
653
- * a testid, `filter(...)`, or the explicit `first()`/`last()`/`nth(i)`
654
- * opt-outs.
636
+ * action time rather than silently acting on one. Scope to the intended
637
+ * container, an exact role/name, or a stable test ID. Use positional selection
638
+ * only when order is part of what the test verifies.
655
639
  */
656
640
  export interface Locator {
657
641
  // ── Composition (lazy; return a new Locator) ──────────────────────────
@@ -668,13 +652,15 @@ export interface Locator {
668
652
  filter(opts: FilterOptions): Locator;
669
653
  /** Intersect with another locator (both must match the same element). */
670
654
  and(other: Locator): Locator;
671
- /** Union with another locator (either may match). */
655
+ /** Union with another locator (either may match). If both match, a
656
+ * single-element operation still fails strictness. */
672
657
  or(other: Locator): Locator;
673
- /** Explicit strict-mode opt-out: the first resolved element. */
658
+ /** First resolved element. Use only when position is the intended contract;
659
+ * does not establish identity or relate this match to another locator. */
674
660
  first(): Locator;
675
- /** Explicit strict-mode opt-out: the last resolved element. */
661
+ /** Last resolved element. Prefer a unique identity unless testing order. */
676
662
  last(): Locator;
677
- /** Explicit strict-mode opt-out: the i-th resolved element (0-based). */
663
+ /** i-th resolved element (0-based). Prefer a unique identity unless testing order. */
678
664
  nth(index: number): Locator;
679
665
 
680
666
  // ── Actions (auto-wait; default 10s, `{ timeout }` overrides) ─────────
@@ -726,11 +712,15 @@ export interface Locator {
726
712
  dragTo(target: Locator, opts?: TimeoutOption): Promise<void>;
727
713
 
728
714
  // ── Reads (wrapped so `expect(...)` nests under the step) ─────────────
715
+ /** One observed value; use `await expect(locator).toHaveText(...)` to
716
+ * retry until expected text appears. Wrapping preserves provenance, not retries. */
729
717
  textContent(opts?: TimeoutOption): Promise<Wrapped<string | null>>;
730
718
  innerText(opts?: TimeoutOption): Promise<Wrapped<string>>;
731
719
  innerHTML(opts?: TimeoutOption): Promise<Wrapped<string>>;
732
720
  inputValue(opts?: TimeoutOption): Promise<Wrapped<string>>;
733
721
  getAttribute(name: string, opts?: TimeoutOption): Promise<Wrapped<string | null>>;
722
+ /** Immediate visibility observation. Use `await expect(locator).toBeVisible()`
723
+ * for readiness; use `.unwrap()` if branching on this observation. */
734
724
  isVisible(): Promise<Wrapped<boolean>>;
735
725
  isHidden(): Promise<Wrapped<boolean>>;
736
726
  isEnabled(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
@@ -738,8 +728,9 @@ export interface Locator {
738
728
  isChecked(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
739
729
  isEditable(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
740
730
  count(): Promise<Wrapped<number>>;
741
- /** Resolve to one locator per current match (handle-free — each is pinned
742
- * with `nth(i)`). One recorded event. */
731
+ /** One locator per current match, using `nth(i)` (positions, not pinned
732
+ * identities). Does not wait for a list to finish loading; assert its ready
733
+ * state first, and prefer stable IDs if items can move during iteration. */
743
734
  all(): Promise<Locator[]>;
744
735
  allTextContents(): Promise<Wrapped<string[]>>;
745
736
  allInnerTexts(): Promise<Wrapped<string[]>>;
@@ -858,23 +849,22 @@ export function makeLocator(
858
849
  ) as Promise<Wrapped<T>>;
859
850
  };
860
851
 
861
- // Silent, non-recorded reads for expect(locator) matchers to poll.
852
+ // Native assertion and recording seam for expect(locator).
862
853
  const probe: LocatorProbe = {
863
854
  label,
864
- isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
865
- textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
866
- innerText: (timeout) => backend.silentRead((page) => lower(page, chain).innerText({ timeout })),
867
- allTextContents: () => backend.silentRead((page) => lower(page, chain).allTextContents()),
868
- allInnerTexts: () => backend.silentRead((page) => lower(page, chain).allInnerTexts()),
869
- inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
870
- count: () => backend.silentRead((page) => lower(page, chain).count()),
855
+ assert: async (request) => {
856
+ const { assertLocator } = await import("./playwright-assertions.js");
857
+ return backend.silentRead((page) => assertLocator(lower(page, chain), request));
858
+ },
871
859
  nearMiss: () => backend.silentRead((page) => hintBlock(page)),
872
- isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
873
- isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
874
860
  settle: async (action, waitedMs, error, opts) => {
875
861
  const fields: Partial<RecordableFields> = { selector: label, target };
876
862
  if (opts && opts.reveal) {
877
- await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
863
+ try {
864
+ await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
865
+ } catch {
866
+ // A page closing after evaluation must not replace the assertion result.
867
+ }
878
868
  }
879
869
  return backend.recordSettled(action, fields, waitedMs, error);
880
870
  },
@@ -0,0 +1,231 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { chromium, type Browser as PWBrowser, type Page } from "playwright-core";
4
+ import { expect as assert, ExpectationError, type Browser, type LocatorMatchers } from "./index.js";
5
+ import { attachBrowserProbe, desktopStrategy, getLocatorProbe, makeLocator, type LocatorBackend } from "./locator.js";
6
+ import { assertURL } from "./playwright-assertions.js";
7
+ import { recordBrowser, startRecording, stopRecording } from "./recorder.js";
8
+
9
+ // Opt in after `bunx playwright install chromium --only-shell`, or run
10
+ // automatically when a system/cached full Chromium is available.
11
+ const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? undefined;
12
+ const available = !!process.env.SPECTEST_TEST_BROWSER_ASSERTIONS || !!executablePath || existsSync(chromium.executablePath());
13
+
14
+ describe.skipIf(!available)("Spectest assertions backed by real Playwright/Chromium", () => {
15
+ let browser: PWBrowser;
16
+ let page: Page;
17
+ let backend: LocatorBackend;
18
+ const locator = (css: string) => makeLocator(backend, desktopStrategy, { steps: [{ m: "locator", args: [css] }] });
19
+ const session = () => attachBrowserProbe({}, {
20
+ assertURL: (negated, expected, opts) => assertURL(page, negated, expected, opts),
21
+ settle: (action, durationMs, error) => backend.recordSettled(action, { url: page.url() }, durationMs, error),
22
+ }) as Browser;
23
+
24
+ beforeAll(async () => { browser = await chromium.launch({ executablePath }); });
25
+ beforeEach(async () => {
26
+ page = await browser.newPage();
27
+ backend = {
28
+ async pageOp() { throw new Error("assertions must not perform recorded reads"); },
29
+ silentRead: fn => fn(page),
30
+ async rawTap() {},
31
+ async recordSettled(action, fields, durationMs, error) {
32
+ return recordBrowser({ action, ...fields, durationMs, error });
33
+ },
34
+ };
35
+ });
36
+ afterEach(async () => {
37
+ stopRecording();
38
+ await page.context().close();
39
+ });
40
+ afterAll(async () => { await browser?.close(); });
41
+
42
+ test("re-resolves a scoped selector when its DOM node is replaced", async () => {
43
+ await page.setContent('<section aria-label="Order"><span role="status">Saving</span></section>');
44
+ const status = locator("section").filter({ has: locator('[role="status"]') }).getByRole("status");
45
+ startRecording();
46
+ // This timer simulates asynchronous application work, not a test wait.
47
+ await page.evaluate(() => setTimeout(() => {
48
+ document.querySelector('[role="status"]')!.outerHTML = '<span role="status">Saved</span>';
49
+ }, 150));
50
+ await assert(status, "order persisted").toHaveText("Saved", { timeout: 2000 });
51
+ const events = stopRecording();
52
+ expect(events).toHaveLength(2);
53
+ expect(events[0]).toMatchObject({ kind: "browser", action: "toHaveText" });
54
+ expect(events[1]).toMatchObject({ passed: true, sourceSeq: events[0]!.seq, message: "order persisted" });
55
+ });
56
+
57
+ test("waits through navigation and resolves an initially absent element", async () => {
58
+ const pending = assert(locator("#saved")).toHaveText("Saved", { timeout: 2000 });
59
+ await page.goto('data:text/html,<div id="saved">Saved</div>');
60
+ await pending;
61
+ });
62
+
63
+ test("an existing SDK locator resolves a replacement backend page at assertion time", async () => {
64
+ const target = locator("#saved");
65
+ const previous = page;
66
+ page = await browser.newPage();
67
+ await previous.context().close();
68
+ await page.setContent('<div id="saved">Saved</div>');
69
+ await assert(target).toHaveText("Saved");
70
+ });
71
+
72
+ test("all exposed matcher forms preserve positive and negative results", async () => {
73
+ await page.setContent('<input id="input" value="Saved"><input id="check" type="checkbox" checked><button disabled>Save</button><div id="text">Hello<span hidden> hidden</span></div><li>One</li><li>Two</li>');
74
+ await assert(locator("#input")).toBeVisible();
75
+ await assert(locator("#input")).not.toBeHidden();
76
+ await assert(locator("#input")).toBeEnabled();
77
+ await assert(locator("#input")).not.toBeDisabled();
78
+ await assert(locator("button")).toBeDisabled();
79
+ await assert(locator("button")).not.toBeEnabled();
80
+ await assert(locator("#check")).toBeChecked();
81
+ await page.locator("#check").uncheck();
82
+ await assert(locator("#check")).not.toBeChecked();
83
+ await assert(locator("#check")).not.toHaveValue("wrong");
84
+ await assert(locator("#input")).toHaveValue("Saved");
85
+ await assert(locator("#text")).toHaveText("Hello", { useInnerText: true });
86
+ await assert(locator("#text")).toContainText("hidden");
87
+ await assert(locator("#text span")).toBeHidden();
88
+ await assert(locator("#text")).not.toContainText("absent");
89
+ await assert(locator("li")).toHaveText(["One", /Two/]);
90
+ await assert(locator("li")).toContainText(["Two"]);
91
+ await assert(locator("li")).not.toContainText(["Two", "One"]);
92
+ await assert(locator("li")).not.toHaveText(["One"]);
93
+ await assert(locator("li")).toHaveCount(2);
94
+ await assert(locator("li")).not.toHaveCount(0);
95
+ });
96
+
97
+ test("absent elements pass disappearance/count checks but not negative text", async () => {
98
+ const absent = locator("#missing");
99
+ await assert(absent).toBeHidden();
100
+ await assert(absent).not.toBeVisible();
101
+ await assert(absent).toHaveCount(0);
102
+ await assert(absent).toHaveText([]);
103
+ await expect(assert(absent).not.toHaveText("Saved", { timeout: 60 })).rejects.toThrow("element(s) not found");
104
+ await expect(assert(absent).not.toHaveValue("Saved", { timeout: 60 })).rejects.toBeInstanceOf(ExpectationError);
105
+ });
106
+
107
+ test("string whitespace, raw regex text, case overrides and regex state use native semantics", async () => {
108
+ await page.setContent('<pre id="text">a\nb</pre><div id="saved">SAVED</div>');
109
+ await assert(locator("#text")).toHaveText("a b");
110
+ await assert(locator("#text")).toHaveText("a\u00a0b");
111
+ await assert(locator("#text")).toHaveText(/a\nb/);
112
+ await assert(locator("#text")).not.toHaveText(/a b/);
113
+ await assert(locator("#saved")).not.toHaveText(/saved/i, { ignoreCase: false });
114
+ await assert(locator("#saved")).toHaveText(/saved/, { ignoreCase: true });
115
+ for (const pattern of [/SAVED/g, /SAVED/y]) {
116
+ pattern.lastIndex = 3;
117
+ await assert(locator("#saved")).toHaveText(pattern);
118
+ await expect(assert(locator("#saved")).not.toHaveText(pattern, { timeout: 180 })).rejects.toThrow();
119
+ await assert(locator("#saved")).toHaveText(pattern);
120
+ }
121
+ });
122
+
123
+ test("input regexes cannot make a negative assertion pass on unchanged text", async () => {
124
+ await page.setContent('<input value="Saved">');
125
+ await expect(assert(locator("input")).not.toHaveValue(/Saved/g, { timeout: 180 })).rejects.toThrow();
126
+ });
127
+
128
+ test("one assertion deadline bounds a missing input regardless of action timeout", async () => {
129
+ page.setDefaultTimeout(10_000);
130
+ const started = performance.now();
131
+ const input = locator("input");
132
+ getLocatorProbe(input).nearMiss = async () => ""; // Measure assertion budget, excluding supplemental diagnostics.
133
+ await expect(assert(input).toHaveValue("Saved", { timeout: 100 })).rejects.toThrow();
134
+ expect(performance.now() - started).toBeLessThan(3000);
135
+ });
136
+
137
+ test("timeout zero disables the assertion timeout instead of checking once", async () => {
138
+ await page.setContent('<div id="status">Saving</div>');
139
+ await page.evaluate(() => setTimeout(() => { document.querySelector("div")!.textContent = "Saved"; }, 150));
140
+ await assert(locator("#status")).toHaveText("Saved", { timeout: 0 });
141
+ });
142
+
143
+ test("strictness and invalid selectors fail negative assertions with native details", async () => {
144
+ await page.setContent("<div>One</div><div>Two</div>");
145
+ await expect(assert(locator("div")).not.toHaveText("Saved", { timeout: 60 })).rejects.toThrow("strict mode violation");
146
+ await expect(assert(locator("[")).not.toHaveText("Saved", { timeout: 60 })).rejects.toThrow();
147
+ });
148
+
149
+ const checks: [string, (m: LocatorMatchers) => Promise<void>][] = [
150
+ ["visible", m => m.toBeVisible({ timeout: 100 })],
151
+ ["hidden", m => m.toBeHidden({ timeout: 100 })],
152
+ ["text", m => m.toHaveText("Saved", { timeout: 100 })],
153
+ ["contains", m => m.toContainText("Saved", { timeout: 100 })],
154
+ ["value", m => m.toHaveValue("Saved", { timeout: 100 })],
155
+ ["count", m => m.toHaveCount(1, { timeout: 100 })],
156
+ ["enabled", m => m.toBeEnabled({ timeout: 100 })],
157
+ ["disabled", m => m.toBeDisabled({ timeout: 100 })],
158
+ ["checked", m => m.toBeChecked({ timeout: 100 })],
159
+ ];
160
+ for (const [name, check] of checks) {
161
+ test(`a closed page cannot satisfy a negative ${name} assertion`, async () => {
162
+ const target = locator("input");
163
+ await page.close();
164
+ startRecording();
165
+ await expect(check(assert(target).not)).rejects.toBeInstanceOf(ExpectationError);
166
+ const events = stopRecording();
167
+ expect(events).toHaveLength(2);
168
+ expect(events[1]).toMatchObject({ passed: false, negated: true, sourceSeq: events[0]!.seq });
169
+ });
170
+ }
171
+
172
+ test("failure records the native received value and call log", async () => {
173
+ await page.setContent('<div id="status">Draft</div>');
174
+ startRecording();
175
+ await expect(assert(locator("#status")).toHaveText("Saved", { timeout: 60 })).rejects.toThrow("Call log:");
176
+ expect(stopRecording()[1]).toMatchObject({ passed: false, actual: "Draft", expected: "Saved" });
177
+ });
178
+
179
+ test("URL assertions wait for navigation and retain Spectest glob syntax", async () => {
180
+ const view = session();
181
+ await page.route("http://spectest.test/**", route => route.fulfill({ body: "ready" }));
182
+ const pending = assert(view).toHaveURL("**/dashboard", { timeout: 2000 });
183
+ await page.goto("http://spectest.test/dashboard");
184
+ await pending;
185
+ await assert(view).toHaveURL("http://spectest.test/*");
186
+ await assert(view).toHaveURL(/dashboard/g);
187
+ await assert(view).toHaveURL(url => url.pathname === "/dashboard");
188
+ await assert(view).not.toHaveURL("**/login");
189
+ await expect(assert(view).not.toHaveURL(/dashboard/g, { timeout: 180 })).rejects.toThrow();
190
+ });
191
+
192
+ test("closed pages fail negative URL assertions instead of accepting a cached URL", async () => {
193
+ await page.close();
194
+ startRecording();
195
+ await expect(assert(session()).not.toHaveURL("**/dashboard", { timeout: 60 })).rejects.toBeInstanceOf(ExpectationError);
196
+ const events = stopRecording();
197
+ expect(events[1]).toMatchObject({ passed: false, matcher: "toHaveURL", sourceSeq: events[0]!.seq });
198
+ });
199
+ });
200
+
201
+ // Exercise the production session branding/recording as well as the real
202
+ // native engine above. browser.ts intentionally uses system Chromium.
203
+ test.skipIf(!executablePath)("browser backend preserves assertion provenance and failure page capture", async () => {
204
+ const { openBrowser } = await import("./browser.js");
205
+ const replaySteps: unknown[] = [];
206
+ const view = await openBrowser({
207
+ url: 'data:text/html,<div id="status">Saved</div>',
208
+ recorder: { sessionId: "assertion-test", recordStep: step => { replaySteps.push(step); } },
209
+ });
210
+ try {
211
+ startRecording();
212
+ await assert(view.locator("#status"), "saved").toHaveText("Saved");
213
+ await assert(view).toHaveURL("data:text/html,**");
214
+ await expect(assert(view.locator("#status")).toHaveText("Draft", { timeout: 60 })).rejects.toThrow();
215
+ const events = stopRecording();
216
+ const assertions = events.filter(e => e.kind === "assertion");
217
+ const steps = events.filter(e => e.kind === "browser");
218
+ expect(assertions).toHaveLength(3);
219
+ expect(steps).toHaveLength(3);
220
+ expect(assertions.map(a => a.sourceSeq)).toEqual(steps.map(s => s.seq));
221
+ expect(assertions.map(a => a.passed)).toEqual([true, true, false]);
222
+ expect(assertions[2]).toMatchObject({ actual: "Saved", expected: "Draft" });
223
+ expect(steps[2]!.pageStructure).toContain("Saved");
224
+ expect(steps.every(step => step.sessionId === "assertion-test")).toBe(true);
225
+ expect(steps[0]!.targetNodeId).toBeGreaterThan(0);
226
+ expect(replaySteps.length).toBeGreaterThan(0);
227
+ } finally {
228
+ stopRecording();
229
+ await view.close();
230
+ }
231
+ });
@@ -0,0 +1,49 @@
1
+ // Loaded only when a browser assertion runs. Use the public matcher API;
2
+ // Playwright owns selector resolution, retries, deadlines and negation.
3
+ import { expect } from "@playwright/test";
4
+ import type { Locator, Page } from "playwright-core";
5
+ import type { BrowserMatchers, LocatorMatchers } from "./index.js";
6
+ import { DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
7
+ import { globToRegExp } from "./url-match.js";
8
+
9
+ export type LocatorAssertionRequest = {
10
+ [K in keyof LocatorMatchers]: {
11
+ matcher: K;
12
+ args: Parameters<LocatorMatchers[K]>;
13
+ negated: boolean;
14
+ }
15
+ }[keyof LocatorMatchers];
16
+
17
+ const browserExpect = expect.configure({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
18
+
19
+ export async function assertLocator(locator: Locator, request: LocatorAssertionRequest): Promise<void> {
20
+ const positive = browserExpect(locator);
21
+ const assertion = request.negated ? positive.not : positive;
22
+ switch (request.matcher) {
23
+ case "toBeVisible": return assertion.toBeVisible(...request.args);
24
+ case "toBeHidden": return assertion.toBeHidden(...request.args);
25
+ case "toHaveText": return assertion.toHaveText(...request.args);
26
+ case "toContainText": return assertion.toContainText(...request.args);
27
+ case "toHaveValue": return assertion.toHaveValue(...request.args);
28
+ case "toHaveCount": return assertion.toHaveCount(...request.args);
29
+ case "toBeEnabled": return assertion.toBeEnabled(...request.args);
30
+ case "toBeDisabled": return assertion.toBeDisabled(...request.args);
31
+ case "toBeChecked": return assertion.toBeChecked(...request.args);
32
+ default: {
33
+ const unsupported: never = request;
34
+ throw new Error(`Unsupported locator assertion: ${String(unsupported)}`);
35
+ }
36
+ }
37
+ }
38
+
39
+ export async function assertURL(
40
+ page: Page,
41
+ negated: boolean,
42
+ ...[expected, options]: Parameters<BrowserMatchers["toHaveURL"]>
43
+ ): Promise<void> {
44
+ const positive = browserExpect(page);
45
+ // Spectest has always accepted URL globs. Convert just that syntax; use
46
+ // native URL assertions for evaluation (including predicates and .not).
47
+ const pattern = typeof expected === "string" ? globToRegExp(expected) : expected;
48
+ await (negated ? positive.not : positive).toHaveURL(pattern, options);
49
+ }
@@ -1,11 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
- containsText,
5
- containsTextArray,
6
4
  escapeInvisible,
7
- matchesText,
8
- matchesTextArray,
9
5
  normalizeWhiteSpace,
10
6
  textDifferenceNote,
11
7
  } from "./text-match.js";
@@ -31,59 +27,6 @@ describe("normalizeWhiteSpace", () => {
31
27
  });
32
28
  });
33
29
 
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
30
  describe("escapeInvisible", () => {
88
31
  test("spells out the characters that print as nothing", () => {
89
32
  expect(escapeInvisible(JSON.stringify(SEK))).toBe('"15\\u00a0000 kr"');
package/src/text-match.ts CHANGED
@@ -1,25 +1,4 @@
1
- // Text comparison for the web-first matchers, and readable notes for the
2
- // failures that invisible characters cause.
3
- //
4
- // Two rules, and they are deliberately different:
5
- //
6
- // * **Matching is Playwright's.** `expect(locator).toHaveText(...)` and
7
- // `toContainText(...)` normalize whitespace on BOTH sides exactly as
8
- // playwright's own text engine does (`normalizeWhiteSpace` in
9
- // playwright-core). Without this the SDK disagreed with itself: the same
10
- // string matched through `getByText("15 000 kr")` (playwright's engine,
11
- // which normalizes) and failed through `toContainText("15 000 kr")`
12
- // (ours, which did a raw `includes`). The case that reported it is
13
- // Swedish number formatting — `Intl.NumberFormat("sv-SE")` groups
14
- // thousands with U+00A0 NO-BREAK SPACE, and JavaScript's `\s` covers
15
- // U+00A0, so normalization makes a typed space match it.
16
- //
17
- // * **Diagnostics are wider.** A value the author compares themselves
18
- // (`expect(body).toContain(...)`, `toHaveValue`) is still exact, as it
19
- // must be — so when such a comparison fails on characters that print the
20
- // same, the message has to say so. `textDifferenceNote` reports the
21
- // invisible or look-alike characters that are the whole difference, and
22
- // `escapeInvisible` makes the two printed strings differ on screen.
1
+ // Text diagnostics only. Browser comparisons are delegated to Playwright.
23
2
 
24
3
  /** Characters that are invisible on screen (or print as an ordinary space)
25
4
  * and their Unicode names, for a message a reader can act on. */
@@ -87,44 +66,10 @@ export function normalizeWhiteSpace(text: string): string {
87
66
  }
88
67
 
89
68
  export interface TextMatchOptions {
90
- /** Compare without regard to letter case. On a RegExp this adds the `i`
91
- * flag, as playwright's own `ignoreCase` does. */
69
+ /** Override case sensitivity, including a RegExp's `i` flag. */
92
70
  ignoreCase?: boolean;
93
71
  }
94
72
 
95
- function caseFold(s: string, opts?: TextMatchOptions): string {
96
- return opts?.ignoreCase ? s.toLowerCase() : s;
97
- }
98
-
99
- function withIgnoreCase(re: RegExp, opts?: TextMatchOptions): RegExp {
100
- if (!opts?.ignoreCase || re.flags.includes("i")) return re;
101
- return new RegExp(re.source, `${re.flags}i`);
102
- }
103
-
104
- /** The element's text equals `expected`, with whitespace normalized on both
105
- * sides. A RegExp is tested against the normalized text. */
106
- export function matchesText(
107
- actual: string,
108
- expected: string | RegExp,
109
- opts?: TextMatchOptions,
110
- ): boolean {
111
- const a = normalizeWhiteSpace(actual);
112
- if (expected instanceof RegExp) return withIgnoreCase(expected, opts).test(a);
113
- return caseFold(a, opts) === caseFold(normalizeWhiteSpace(expected), opts);
114
- }
115
-
116
- /** The element's text contains `expected`, with whitespace normalized on both
117
- * sides. A RegExp is tested against the normalized text. */
118
- export function containsText(
119
- actual: string,
120
- expected: string | RegExp,
121
- opts?: TextMatchOptions,
122
- ): boolean {
123
- const a = normalizeWhiteSpace(actual);
124
- if (expected instanceof RegExp) return withIgnoreCase(expected, opts).test(a);
125
- return caseFold(a, opts).includes(caseFold(normalizeWhiteSpace(expected), opts));
126
- }
127
-
128
73
  // ── Diagnostics ───────────────────────────────────────────────────────────
129
74
 
130
75
  /** `" "` for an invisible character, the character itself otherwise.
@@ -242,44 +187,3 @@ export function textDifferenceNote(
242
187
  }
243
188
  return "";
244
189
  }
245
-
246
- // ── Array forms (`toHaveText([...])` / `toContainText([...])`) ────────────
247
- //
248
- // Playwright's own rule, from its injected script: the expected items are
249
- // matched against the received texts **greedily, in order**, and the lengths
250
- // must agree for `toHaveText` but not for `toContainText`. So
251
- // `toContainText(["Two", "Four"])` passes against a four-row list, while
252
- // `toHaveText(["Two", "Four"])` does not.
253
-
254
- function matchesInOrder(
255
- actual: string[],
256
- expected: (string | RegExp)[],
257
- item: (a: string, e: string | RegExp) => boolean,
258
- ): boolean {
259
- let want = 0;
260
- for (let i = 0; i < actual.length && want < expected.length; i++) {
261
- if (item(actual[i]!, expected[want]!)) want++;
262
- }
263
- return want === expected.length;
264
- }
265
-
266
- /** Every element's text equals the item at its position, and there are
267
- * exactly as many elements as items. */
268
- export function matchesTextArray(
269
- actual: string[],
270
- expected: (string | RegExp)[],
271
- opts?: TextMatchOptions,
272
- ): boolean {
273
- if (actual.length !== expected.length) return false;
274
- return matchesInOrder(actual, expected, (a, e) => matchesText(a, e, opts));
275
- }
276
-
277
- /** The elements' texts contain every item, in order. Extra elements between
278
- * the matches are allowed. */
279
- export function containsTextArray(
280
- actual: string[],
281
- expected: (string | RegExp)[],
282
- opts?: TextMatchOptions,
283
- ): boolean {
284
- return matchesInOrder(actual, expected, (a, e) => containsText(a, e, opts));
285
- }