@specific.dev/spectest 0.122.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/README.md +10 -1
- package/dist/browser.js +7 -5
- package/dist/index.d.ts +22 -17
- package/dist/index.js +70 -247
- package/dist/locator-errors.js +2 -1
- package/dist/locator.d.ts +32 -39
- package/dist/locator.js +12 -14
- package/dist/playwright-assertions.d.ts +11 -0
- package/dist/playwright-assertions.js +32 -0
- package/dist/text-match.d.ts +1 -14
- package/dist/text-match.js +1 -73
- package/dist/url-match.d.ts +1 -0
- package/dist/url-match.js +5 -9
- package/package.json +3 -2
- package/src/browser.ts +7 -5
- package/src/index.ts +105 -356
- package/src/locator-assertions.test.ts +59 -0
- package/src/locator-errors.test.ts +4 -4
- package/src/locator-errors.ts +2 -1
- package/src/locator.ts +40 -50
- package/src/playwright-assertions.test.ts +231 -0
- package/src/playwright-assertions.ts +49 -0
- package/src/text-match.test.ts +0 -57
- package/src/text-match.ts +2 -98
- package/src/url-match.ts +5 -9
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
412
|
-
*
|
|
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
|
-
|
|
417
|
-
|
|
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
|
|
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.
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
661
|
+
/** Last resolved element. Prefer a unique identity unless testing order. */
|
|
676
662
|
last(): Locator;
|
|
677
|
-
/**
|
|
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
|
-
/**
|
|
742
|
-
*
|
|
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
|
-
//
|
|
852
|
+
// Native assertion and recording seam for expect(locator).
|
|
862
853
|
const probe: LocatorProbe = {
|
|
863
854
|
label,
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/text-match.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
}
|