@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/README.md
CHANGED
|
@@ -8,13 +8,22 @@ Install the SDK in your project's `spectest/` directory:
|
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
mkdir -p spectest
|
|
11
|
-
npm install --prefix spectest --save-exact @specific.dev/spectest
|
|
11
|
+
npm install --prefix spectest --save-exact @specific.dev/spectest
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/latest)
|
|
15
15
|
and run `spectest docs /installation` for project setup and authentication.
|
|
16
16
|
`spectest docs` contains the complete offline authoring guide.
|
|
17
17
|
|
|
18
|
+
For browser tests, read `spectest docs /tests/browser`; for phone-emulated Expo
|
|
19
|
+
apps, read `spectest docs /tests/mobile`. The shared test model is in `/tests`. Prefer
|
|
20
|
+
unique, scoped role/name or test-ID locators and awaited browser assertions:
|
|
21
|
+
`await expect(browser.getByRole("status")).toHaveText("Saved")`. A one-time read
|
|
22
|
+
inside `expect` does not retry. Observe the exact saved state before dependent
|
|
23
|
+
work; do not replace readiness with sleeps or arbitrary `.first()` selections.
|
|
24
|
+
SDK 0.123.0 uses native Playwright assertions while retaining Spectest's API and
|
|
25
|
+
recording. Ambiguous single-element selectors fail immediately; make them unique.
|
|
26
|
+
|
|
18
27
|
Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
19
28
|
`pubsub`/`pubsubClient` exports and `/components/gcp` entry point are removed.
|
|
20
29
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
package/dist/browser.js
CHANGED
|
@@ -1554,9 +1554,8 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1554
1554
|
const fields = { description: label };
|
|
1555
1555
|
return instrumented("waitForURL", fields, async () => {
|
|
1556
1556
|
try {
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
// `expect(browser).toHaveURL`.
|
|
1557
|
+
// Preserve Spectest's glob dialect. URL assertions use the same
|
|
1558
|
+
// glob compiler through their native matcher adapter.
|
|
1560
1559
|
await holder.page.waitForURL((u) => matchesUrl(u.href, pattern), {
|
|
1561
1560
|
timeout: timeoutMs,
|
|
1562
1561
|
waitUntil: options.waitUntil ?? "commit",
|
|
@@ -1763,12 +1762,15 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1763
1762
|
return instrumented(action, fields, () => fn(holder.page), opts);
|
|
1764
1763
|
},
|
|
1765
1764
|
};
|
|
1766
|
-
// The seam `expect(browser).toHaveURL(...)`
|
|
1765
|
+
// The seam `expect(browser).toHaveURL(...)` uses: a native assertion plus
|
|
1767
1766
|
// the single settled step its assertion nests under — the session twin of
|
|
1768
1767
|
// the locator probe, and what brands this object as a browser session for
|
|
1769
1768
|
// `expect`'s overload dispatch.
|
|
1770
1769
|
attachBrowserProbe(backend, {
|
|
1771
|
-
|
|
1770
|
+
assertURL: async (negated, expected, opts) => {
|
|
1771
|
+
const { assertURL } = await import("./playwright-assertions.js");
|
|
1772
|
+
await assertURL(holder.page, negated, expected, opts);
|
|
1773
|
+
},
|
|
1772
1774
|
settle: (action, waitedMs, error) => backend.recordSettled(action, { url: holder.page.url() }, waitedMs, error),
|
|
1773
1775
|
});
|
|
1774
1776
|
return { backend, detach: endRecording };
|
package/dist/index.d.ts
CHANGED
|
@@ -363,7 +363,8 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
363
363
|
*/
|
|
364
364
|
readonly fakes: FakeHandlesFor<F>;
|
|
365
365
|
/**
|
|
366
|
-
* Poll a predicate until it returns a
|
|
366
|
+
* Poll a predicate until it returns a value other than null, undefined,
|
|
367
|
+
* or false, then return that
|
|
367
368
|
* value. In a test it records one `wait` event for the whole loop (with
|
|
368
369
|
* attempt count, total duration, and the description) instead of one
|
|
369
370
|
* event per probe — useful for "wait until pod Running"-style checks
|
|
@@ -371,19 +372,20 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
371
372
|
* timeline; it's just the loop you'd otherwise hand-roll.
|
|
372
373
|
*
|
|
373
374
|
* - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
|
|
374
|
-
* `intervalMs` and try again.
|
|
375
|
+
* `intervalMs` and try again. A directly returned wrapped value is
|
|
376
|
+
* checked using its underlying value. Use `.unwrap()` when branching
|
|
377
|
+
* on wrapped primitives inside `fn`; wrapper objects are truthy.
|
|
375
378
|
* - Anything else is the success value and is returned, tagged with
|
|
376
379
|
* the wait event's seq. Downstream `expect(...)` on it links to
|
|
377
380
|
* the wait (one logical step), not to N suppressed HTTP calls.
|
|
378
|
-
* -
|
|
379
|
-
*
|
|
380
|
-
* abort.
|
|
381
|
+
* - Instrumented fetch transport failures are retried. Other throws
|
|
382
|
+
* from `fn` propagate immediately; the wait event records the abort.
|
|
381
383
|
* - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
|
|
382
384
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
385
|
+
* The winning attempt's events, or the final failed attempt's events,
|
|
386
|
+
* are retained beneath the wait; earlier attempts are discarded. Use
|
|
387
|
+
* `ctx.poll` for read-only observation of the exact required state.
|
|
388
|
+
* Perform mutations once outside the poll so retries cannot repeat them.
|
|
387
389
|
*/
|
|
388
390
|
poll<T>(description: string, fn: () => T | null | undefined | false | Promise<T | null | undefined | false>, opts?: {
|
|
389
391
|
timeoutMs?: number;
|
|
@@ -1871,9 +1873,10 @@ export interface TextMatcherOptions extends TextMatchOptions {
|
|
|
1871
1873
|
}
|
|
1872
1874
|
/**
|
|
1873
1875
|
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
1874
|
-
* `expect(locator)` matchers.
|
|
1875
|
-
* deadline elapses (default
|
|
1876
|
-
*
|
|
1876
|
+
* `expect(locator)` matchers. Playwright re-resolves the locator until it
|
|
1877
|
+
* passes or a deadline elapses (default 10 s, `{ timeout }` overrides; 0
|
|
1878
|
+
* disables the assertion timeout). Records an assertion event just like a
|
|
1879
|
+
* value `expect`. `await` them — they are async.
|
|
1877
1880
|
*/
|
|
1878
1881
|
export interface LocatorMatchers {
|
|
1879
1882
|
/** The element is present and visible. */
|
|
@@ -1887,8 +1890,9 @@ export interface LocatorMatchers {
|
|
|
1887
1890
|
/**
|
|
1888
1891
|
* The element's text equals `expected`, or matches it when it is a RegExp.
|
|
1889
1892
|
*
|
|
1890
|
-
* **
|
|
1891
|
-
*
|
|
1893
|
+
* **String expectations normalize whitespace on both sides**, as Playwright
|
|
1894
|
+
* does. Regex expectations test the original text. String comparisons trim
|
|
1895
|
+
* the text and collapse every run of whitespace to one space.
|
|
1892
1896
|
* So a typed space matches the no-break space (U+00A0) that
|
|
1893
1897
|
* `Intl.NumberFormat` puts between thousands, and a value wrapped across
|
|
1894
1898
|
* two lines in the markup matches the one-line string you wrote.
|
|
@@ -1899,7 +1903,8 @@ export interface LocatorMatchers {
|
|
|
1899
1903
|
toHaveText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1900
1904
|
/**
|
|
1901
1905
|
* The element's text contains `expected` — a substring, or a RegExp tested
|
|
1902
|
-
* against the text.
|
|
1906
|
+
* against the text. Strings normalize whitespace as in {@link toHaveText};
|
|
1907
|
+
* regexes test the original text.
|
|
1903
1908
|
*
|
|
1904
1909
|
* An **array** asserts that the matched elements contain these texts in
|
|
1905
1910
|
* order; unlike `toHaveText` extra elements between them are allowed.
|
|
@@ -1930,8 +1935,8 @@ export interface LocatorAssertion extends LocatorMatchers {
|
|
|
1930
1935
|
/**
|
|
1931
1936
|
* Auto-retrying assertions for a browser/mobile **session** — Playwright's
|
|
1932
1937
|
* `expect(page)` matchers. Like the locator ones, they poll until the
|
|
1933
|
-
* condition holds or the deadline elapses (default
|
|
1934
|
-
* assertion under a settled browser step.
|
|
1938
|
+
* condition holds or the deadline elapses (default 10 s; 0 disables the
|
|
1939
|
+
* assertion timeout) and record one assertion under a settled browser step.
|
|
1935
1940
|
*/
|
|
1936
1941
|
export interface BrowserMatchers {
|
|
1937
1942
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
1
2
|
import { resolveEntrypoints } from "./interactive.js";
|
|
2
3
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
3
4
|
// `defineEnvironment({ name, services })` once, defines test cases via
|
|
@@ -35,10 +36,9 @@ export { S3Client } from "./s3.js";
|
|
|
35
36
|
// way a real AI client would, with the OAuth flow a test runs through its
|
|
36
37
|
// own browser. See `mcp.ts`.
|
|
37
38
|
export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
|
|
38
|
-
import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe,
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
39
|
+
import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, } from "./locator.js";
|
|
40
|
+
import { escapeInvisible, textDifferenceNote, } from "./text-match.js";
|
|
41
|
+
import { describeUrlPattern } from "./url-match.js";
|
|
42
42
|
import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
|
|
43
43
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
44
44
|
export { ca } from "./environment-ca.js";
|
|
@@ -690,8 +690,53 @@ function expectValue(actual, message) {
|
|
|
690
690
|
return buildMatchers(adoptNullishTag(actual), false, message);
|
|
691
691
|
}
|
|
692
692
|
// ── expect(locator): auto-retrying web-first matchers ─────────────────────
|
|
693
|
-
/**
|
|
694
|
-
|
|
693
|
+
/** Keep the public error type and recording, but never decide whether a
|
|
694
|
+
* browser condition passed here. That belongs entirely to Playwright. */
|
|
695
|
+
async function recordWebAssertion(matcher, expected, negated, message, assert, settle, diagnostics) {
|
|
696
|
+
const started = Date.now();
|
|
697
|
+
let failure;
|
|
698
|
+
let actual;
|
|
699
|
+
try {
|
|
700
|
+
await assert();
|
|
701
|
+
}
|
|
702
|
+
catch (cause) {
|
|
703
|
+
// matcherResult is diagnostic metadata, not an API we use for evaluation.
|
|
704
|
+
// Terminal errors need not carry it. Never re-read the page to manufacture
|
|
705
|
+
// an observed value: it may already differ from the assertion's result.
|
|
706
|
+
actual = cause?.matcherResult?.actual;
|
|
707
|
+
let error = stripVTControlCharacters(cause instanceof Error ? cause.message : String(cause));
|
|
708
|
+
if (typeof actual === "string" && typeof expected === "string") {
|
|
709
|
+
error += textDifferenceNote(actual, expected, matcher === "toContainText" ? "contains" : "equal");
|
|
710
|
+
}
|
|
711
|
+
if (diagnostics) {
|
|
712
|
+
try {
|
|
713
|
+
error += await diagnostics(error);
|
|
714
|
+
}
|
|
715
|
+
catch { /* best effort */ }
|
|
716
|
+
}
|
|
717
|
+
failure = new ExpectationError(error);
|
|
718
|
+
failure.cause = cause;
|
|
719
|
+
}
|
|
720
|
+
const sourceSeq = await settle(Date.now() - started, failure?.message);
|
|
721
|
+
recordAssertion({
|
|
722
|
+
matcher,
|
|
723
|
+
negated,
|
|
724
|
+
passed: !failure,
|
|
725
|
+
// The public Playwright API returns void on success. Omit actual rather
|
|
726
|
+
// than recording a later read as though it were the value that passed.
|
|
727
|
+
actual: actual === undefined ? undefined : safeSerialize(actual),
|
|
728
|
+
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
729
|
+
error: failure?.message,
|
|
730
|
+
message,
|
|
731
|
+
sourceSeq,
|
|
732
|
+
});
|
|
733
|
+
if (failure)
|
|
734
|
+
throw failure;
|
|
735
|
+
}
|
|
736
|
+
function assertionExpected(value) {
|
|
737
|
+
return value instanceof RegExp ? String(value)
|
|
738
|
+
: Array.isArray(value) ? value.map(assertionExpected) : value;
|
|
739
|
+
}
|
|
695
740
|
function buildLocatorAssertion(loc, message) {
|
|
696
741
|
return Object.assign(buildLocatorMatchers(loc, false, message), {
|
|
697
742
|
not: buildLocatorMatchers(loc, true, message),
|
|
@@ -699,212 +744,30 @@ function buildLocatorAssertion(loc, message) {
|
|
|
699
744
|
}
|
|
700
745
|
function buildLocatorMatchers(loc, negated, message) {
|
|
701
746
|
const probe = getLocatorProbe(loc);
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
// it via `sourceSeq` — so a web-first `expect(locator)` assertion carries
|
|
706
|
-
// the same provenance a value assertion does (`expect(await loc.isVisible())`
|
|
707
|
-
// renders identically). Without the anchor step these assertions floated as
|
|
708
|
-
// disconnected top-level "value ✓" rows with no element and no replay seek.
|
|
709
|
-
// `check` returns whether the base condition is satisfied plus the observed
|
|
710
|
-
// value for the timeline.
|
|
711
|
-
const run = async (matcher, timeout, check, describe, expected) => {
|
|
712
|
-
const started = Date.now();
|
|
713
|
-
const budget = timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
714
|
-
const deadline = started + budget;
|
|
715
|
-
let actual;
|
|
716
|
-
// Whether this matcher's subject is meant to be on screen, i.e. whether
|
|
717
|
-
// the step should carry a reveal target for the replay (see
|
|
718
|
-
// `LocatorProbe.settle`). Only the visibility matchers flip with `negated`
|
|
719
|
-
// — `not.toHaveText(...)` is still an element the reader should be looking
|
|
720
|
-
// at — and `toHaveCount(0)` is the third way of saying "expect nothing".
|
|
747
|
+
const run = (request) => {
|
|
748
|
+
const { matcher, args } = request;
|
|
749
|
+
const expected = matcher.startsWith("toHave") || matcher === "toContainText" ? args[0] : undefined;
|
|
721
750
|
const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
|
|
722
751
|
const expectsGone = (visibility && (matcher === "toBeHidden") !== negated) ||
|
|
723
|
-
(matcher === "toHaveCount" && expected === 0);
|
|
724
|
-
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
//
|
|
729
|
-
// Two ways to learn it. A matcher whose probe waits (textContent,
|
|
730
|
-
// inputValue, isEnabled …) has already been told, by the playwright
|
|
731
|
-
// timeout it caught. One whose probe answers instantly (isVisible) has
|
|
732
|
-
// not — `toBeVisible()` on a selector that matches nothing and one that
|
|
733
|
-
// matches a hidden element fail identically — so the count is read once,
|
|
734
|
-
// at failure time only. `toHaveCount` is left alone: "expected count 2,
|
|
735
|
-
// got 0" already says it, and better.
|
|
736
|
-
//
|
|
737
|
-
// Either way the message ends with the near-miss block — what the page
|
|
738
|
-
// holds that is close to what was asked for (see locator-hints.ts) — so
|
|
739
|
-
// the author does not have to run again to find out.
|
|
740
|
-
const nearMiss = async () => {
|
|
741
|
-
try {
|
|
742
|
-
return await probe.nearMiss();
|
|
743
|
-
}
|
|
744
|
-
catch {
|
|
745
|
-
return "";
|
|
746
|
-
}
|
|
747
|
-
};
|
|
748
|
-
const elementFailure = async (err) => {
|
|
749
|
-
if (err !== undefined) {
|
|
750
|
-
const failure = classifyLocatorFailure(probe.label, err);
|
|
751
|
-
if (!failure)
|
|
752
|
-
return undefined;
|
|
753
|
-
return failure.kind === "no-match"
|
|
754
|
-
? failure.message + (await nearMiss())
|
|
755
|
-
: failure.message;
|
|
756
|
-
}
|
|
757
|
-
if (expectsGone || matcher === "toHaveCount")
|
|
758
|
-
return undefined;
|
|
759
|
-
try {
|
|
760
|
-
if ((await probe.count()) === 0) {
|
|
761
|
-
return (`No element matches ${probe.label} (waited ${formatWaited(budget)})` +
|
|
762
|
-
(await nearMiss()));
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
catch {
|
|
766
|
-
/* The page is gone or the chain is invalid — the matcher's own
|
|
767
|
-
description still says what was expected. */
|
|
768
|
-
}
|
|
769
|
-
return undefined;
|
|
770
|
-
};
|
|
771
|
-
// The last error a probe threw, kept for the failure message: a matcher
|
|
772
|
-
// whose read waits for the element (textContent, inputValue, isEnabled …)
|
|
773
|
-
// reports a missing element as a playwright timeout, and that — not
|
|
774
|
-
// "got <error: Timeout 10000ms exceeded …>" — is the sentence to fail with.
|
|
775
|
-
let probeError;
|
|
776
|
-
for (;;) {
|
|
777
|
-
let satisfied;
|
|
778
|
-
try {
|
|
779
|
-
const r = await check();
|
|
780
|
-
satisfied = r.satisfied;
|
|
781
|
-
actual = r.actual;
|
|
782
|
-
probeError = undefined;
|
|
783
|
-
}
|
|
784
|
-
catch (err) {
|
|
785
|
-
probeError = err;
|
|
786
|
-
if (Date.now() < deadline) {
|
|
787
|
-
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
788
|
-
continue;
|
|
789
|
-
}
|
|
790
|
-
satisfied = false;
|
|
791
|
-
// The recorded "actual" is what the timeline shows next to the
|
|
792
|
-
// matcher, so it gets the readable summary too — never the call-log
|
|
793
|
-
// wall a playwright timeout carries.
|
|
794
|
-
actual =
|
|
795
|
-
locatorFailureMessage(probe.label, err) ??
|
|
796
|
-
`<error: ${err?.message ?? String(err)}>`;
|
|
797
|
-
}
|
|
798
|
-
const passed = satisfied !== negated;
|
|
799
|
-
if (passed) {
|
|
800
|
-
const sourceSeq = await probe.settle(matcher, Date.now() - started, undefined, settleOpts);
|
|
801
|
-
recordAssertion({
|
|
802
|
-
matcher,
|
|
803
|
-
negated,
|
|
804
|
-
passed: true,
|
|
805
|
-
actual: safeSerialize(actual),
|
|
806
|
-
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
807
|
-
message,
|
|
808
|
-
sourceSeq,
|
|
809
|
-
});
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
if (Date.now() >= deadline) {
|
|
813
|
-
// The deadline, not the stopwatch: every one of these polled until it
|
|
814
|
-
// ran out, and "(waited 10s)" is both what the author set and the same
|
|
815
|
-
// number twice in a row — a measured 9_987ms is neither.
|
|
816
|
-
const msg = (await elementFailure(probeError)) ??
|
|
817
|
-
`${describe(actual)} (waited ${formatWaited(budget)})`;
|
|
818
|
-
const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
|
|
819
|
-
recordAssertion({
|
|
820
|
-
matcher,
|
|
821
|
-
negated,
|
|
822
|
-
passed: false,
|
|
823
|
-
actual: safeSerialize(actual),
|
|
824
|
-
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
825
|
-
error: msg,
|
|
826
|
-
message,
|
|
827
|
-
sourceSeq,
|
|
828
|
-
});
|
|
829
|
-
throw new ExpectationError(msg);
|
|
830
|
-
}
|
|
831
|
-
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
832
|
-
}
|
|
752
|
+
(matcher === "toHaveCount" && expected === 0 && !negated);
|
|
753
|
+
return recordWebAssertion(matcher, assertionExpected(expected), negated, message, () => probe.assert(request), (duration, error) => probe.settle(matcher, duration, error, { reveal: !expectsGone }),
|
|
754
|
+
// Preserve native diagnostics, adding suggestions only for missing
|
|
755
|
+
// elements. A mismatch/strictness/closed-page failure needs no new read.
|
|
756
|
+
(error) => error.includes("element(s) not found") ? probe.nearMiss() : Promise.resolve(""));
|
|
833
757
|
};
|
|
834
|
-
const not = negated ? " not" : "";
|
|
835
|
-
// The element's text, read the way the caller asked for it. `useInnerText`
|
|
836
|
-
// is playwright's option and means the same here: what the page renders,
|
|
837
|
-
// rather than every character in the subtree.
|
|
838
|
-
const readText = async (opts) => opts?.useInnerText
|
|
839
|
-
? await probe.innerText(opts.timeout)
|
|
840
|
-
: ((await probe.textContent(opts?.timeout)) ?? "");
|
|
841
|
-
const readTexts = async (opts) => opts?.useInnerText ? await probe.allInnerTexts() : await probe.allTextContents();
|
|
842
|
-
const isArrayExpectation = (v) => Array.isArray(v);
|
|
843
|
-
/** Expected values render as themselves; a RegExp renders as its source. */
|
|
844
|
-
const fmtExpected = (v) => v instanceof RegExp
|
|
845
|
-
? String(v)
|
|
846
|
-
: Array.isArray(v)
|
|
847
|
-
? `[${v.map(fmtExpected).join(", ")}]`
|
|
848
|
-
: fmt(v);
|
|
849
|
-
/** What goes on the assertion event: a RegExp cannot be serialized. */
|
|
850
|
-
const expectedValue = (v) => (v instanceof RegExp ? String(v) : Array.isArray(v) ? v.map(expectedValue) : v);
|
|
851
758
|
return {
|
|
852
|
-
toBeVisible: (
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if (isArrayExpectation(expected)) {
|
|
862
|
-
const texts = await readTexts(opts);
|
|
863
|
-
return { satisfied: matchesTextArray(texts, expected, opts), actual: texts };
|
|
864
|
-
}
|
|
865
|
-
const t = await readText(opts);
|
|
866
|
-
return { satisfied: matchesText(t, expected, opts), actual: t };
|
|
867
|
-
}, (a) => `expected ${probe.label}${not} to have text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
868
|
-
textDifferenceNote(a, expected, "equal"), expectedValue(expected)),
|
|
869
|
-
toContainText: (expected, opts) => run("toContainText", opts?.timeout, async () => {
|
|
870
|
-
if (isArrayExpectation(expected)) {
|
|
871
|
-
const texts = await readTexts(opts);
|
|
872
|
-
return { satisfied: containsTextArray(texts, expected, opts), actual: texts };
|
|
873
|
-
}
|
|
874
|
-
const t = await readText(opts);
|
|
875
|
-
return { satisfied: containsText(t, expected, opts), actual: t };
|
|
876
|
-
}, (a) => `expected ${probe.label}${not} to contain text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
877
|
-
textDifferenceNote(a, expected, "contains"), expectedValue(expected)),
|
|
878
|
-
// Deliberately NOT whitespace-normalized: an input's value is data the
|
|
879
|
-
// user typed or the app set, not rendered text, and playwright compares
|
|
880
|
-
// it exactly for the same reason. When that exactness is what failed,
|
|
881
|
-
// `textDifferenceNote` says so instead of leaving two identical-looking
|
|
882
|
-
// strings on screen.
|
|
883
|
-
toHaveValue: (expected, opts) => run("toHaveValue", opts?.timeout, async () => {
|
|
884
|
-
const v = await probe.inputValue(opts?.timeout);
|
|
885
|
-
const satisfied = expected instanceof RegExp ? expected.test(v) : v === expected;
|
|
886
|
-
return { satisfied, actual: v };
|
|
887
|
-
}, (a) => `expected ${probe.label}${not} to have value ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
888
|
-
textDifferenceNote(a, expected, "equal"), expectedValue(expected)),
|
|
889
|
-
toHaveCount: (expected, opts) => run("toHaveCount", opts?.timeout, async () => {
|
|
890
|
-
const c = await probe.count();
|
|
891
|
-
return { satisfied: c === expected, actual: c };
|
|
892
|
-
}, (a) => `expected ${probe.label}${not} to have count ${expected}, got ${fmt(a)}`, expected),
|
|
893
|
-
toBeEnabled: (opts) => run("toBeEnabled", opts?.timeout, async () => {
|
|
894
|
-
const v = await probe.isEnabled(opts?.timeout);
|
|
895
|
-
return { satisfied: v, actual: v };
|
|
896
|
-
}, () => `expected ${probe.label}${not} to be enabled`),
|
|
897
|
-
toBeDisabled: (opts) => run("toBeDisabled", opts?.timeout, async () => {
|
|
898
|
-
const v = await probe.isEnabled(opts?.timeout);
|
|
899
|
-
return { satisfied: !v, actual: v };
|
|
900
|
-
}, () => `expected ${probe.label}${not} to be disabled`),
|
|
901
|
-
toBeChecked: (opts) => run("toBeChecked", opts?.timeout, async () => {
|
|
902
|
-
const v = await probe.isChecked(opts?.timeout);
|
|
903
|
-
return { satisfied: v, actual: v };
|
|
904
|
-
}, () => `expected ${probe.label}${not} to be checked`),
|
|
759
|
+
toBeVisible: (...args) => run({ matcher: "toBeVisible", args, negated }),
|
|
760
|
+
toBeHidden: (...args) => run({ matcher: "toBeHidden", args, negated }),
|
|
761
|
+
toHaveText: (...args) => run({ matcher: "toHaveText", args, negated }),
|
|
762
|
+
toContainText: (...args) => run({ matcher: "toContainText", args, negated }),
|
|
763
|
+
toHaveValue: (...args) => run({ matcher: "toHaveValue", args, negated }),
|
|
764
|
+
toHaveCount: (...args) => run({ matcher: "toHaveCount", args, negated }),
|
|
765
|
+
toBeEnabled: (...args) => run({ matcher: "toBeEnabled", args, negated }),
|
|
766
|
+
toBeDisabled: (...args) => run({ matcher: "toBeDisabled", args, negated }),
|
|
767
|
+
toBeChecked: (...args) => run({ matcher: "toBeChecked", args, negated }),
|
|
905
768
|
};
|
|
906
769
|
}
|
|
907
|
-
// ── expect(browser):
|
|
770
|
+
// ── expect(browser): native page assertions ─────────────────────────────
|
|
908
771
|
function buildBrowserAssertion(session, message) {
|
|
909
772
|
return Object.assign(buildBrowserMatchers(session, false, message), {
|
|
910
773
|
not: buildBrowserMatchers(session, true, message),
|
|
@@ -912,48 +775,8 @@ function buildBrowserAssertion(session, message) {
|
|
|
912
775
|
}
|
|
913
776
|
function buildBrowserMatchers(session, negated, message) {
|
|
914
777
|
const probe = getBrowserProbe(session);
|
|
915
|
-
const not = negated ? " not" : "";
|
|
916
778
|
return {
|
|
917
|
-
toHaveURL:
|
|
918
|
-
const started = Date.now();
|
|
919
|
-
const deadline = started + (opts?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
920
|
-
// `probe.url()` is a synchronous unrecorded read, so the poll costs
|
|
921
|
-
// nothing and emits nothing — one settled step at the end, exactly like
|
|
922
|
-
// the locator matchers.
|
|
923
|
-
const label = describeUrlPattern(expected);
|
|
924
|
-
for (;;) {
|
|
925
|
-
const actual = probe.url();
|
|
926
|
-
if (matchesUrl(actual, expected) !== negated) {
|
|
927
|
-
const sourceSeq = await probe.settle("toHaveURL", Date.now() - started);
|
|
928
|
-
recordAssertion({
|
|
929
|
-
matcher: "toHaveURL",
|
|
930
|
-
negated,
|
|
931
|
-
passed: true,
|
|
932
|
-
actual: safeSerialize(actual),
|
|
933
|
-
expected: label,
|
|
934
|
-
message,
|
|
935
|
-
sourceSeq,
|
|
936
|
-
});
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
if (Date.now() >= deadline) {
|
|
940
|
-
const msg = `expected page URL${not} to match ${label}, got ${fmt(actual)}`;
|
|
941
|
-
const sourceSeq = await probe.settle("toHaveURL", Date.now() - started, msg);
|
|
942
|
-
recordAssertion({
|
|
943
|
-
matcher: "toHaveURL",
|
|
944
|
-
negated,
|
|
945
|
-
passed: false,
|
|
946
|
-
actual: safeSerialize(actual),
|
|
947
|
-
expected: label,
|
|
948
|
-
error: msg,
|
|
949
|
-
message,
|
|
950
|
-
sourceSeq,
|
|
951
|
-
});
|
|
952
|
-
throw new ExpectationError(msg);
|
|
953
|
-
}
|
|
954
|
-
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
955
|
-
}
|
|
956
|
-
},
|
|
779
|
+
toHaveURL: (expected, opts) => recordWebAssertion("toHaveURL", describeUrlPattern(expected), negated, message, () => probe.assertURL(negated, expected, opts), (duration, error) => probe.settle("toHaveURL", duration, error)),
|
|
957
780
|
};
|
|
958
781
|
}
|
|
959
782
|
/**
|
package/dist/locator-errors.js
CHANGED
|
@@ -104,7 +104,8 @@ function parseStrictViolation(err, label) {
|
|
|
104
104
|
.slice(0, 3)
|
|
105
105
|
.map((l) => ` - ${l.trim().replace(/^\d+\)\s*/, "")}`);
|
|
106
106
|
const head = `${label} matches ${m[1]} elements, so it is ambiguous` +
|
|
107
|
-
" —
|
|
107
|
+
" — scope to the intended container, use an exact role/name, or a stable test ID; " +
|
|
108
|
+
"use .first()/.nth(i) only when position is part of the test's contract";
|
|
108
109
|
return {
|
|
109
110
|
kind: "strict",
|
|
110
111
|
message: matches.length ? `${head}:\n${matches.join("\n")}` : head,
|