@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/README.md +29 -1
- package/dist/browser.js +7 -5
- package/dist/components/supabase-coverage.d.ts +7 -0
- package/dist/components/supabase-coverage.js +375 -0
- package/dist/components/supabase.js +30 -7
- 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/components/internal-fetch.test.ts +68 -0
- package/src/components/supabase-coverage.test.ts +261 -0
- package/src/components/supabase-coverage.ts +373 -0
- package/src/components/supabase.ts +30 -7
- 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/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,
|
package/dist/locator.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LocatorAssertionRequest } from "./playwright-assertions.js";
|
|
1
2
|
import type { Page, Locator as PWLocator } from "playwright-core";
|
|
2
3
|
import type { RecordableFields } from "./browser.js";
|
|
3
4
|
import type { Wrapped } from "./inspect.js";
|
|
@@ -140,7 +141,7 @@ export declare function chainLabel(chain: Chain): string;
|
|
|
140
141
|
/** The slice of the browser backend a locator needs. `pageOp` records one
|
|
141
142
|
* browser event and (when `wrap`) provenance-wraps the result so a later
|
|
142
143
|
* `expect(...)` nests under the step. `silentRead` runs a lowered read with
|
|
143
|
-
* NO recording/drain — the
|
|
144
|
+
* NO recording/drain — the assertion path for `expect(locator)` matchers, which
|
|
144
145
|
* must not emit one browser event per retry. */
|
|
145
146
|
export interface LocatorBackend {
|
|
146
147
|
pageOp<T>(action: string, fields: Partial<RecordableFields>, fn: (page: Page) => Promise<T>, opts?: {
|
|
@@ -154,30 +155,18 @@ export interface LocatorBackend {
|
|
|
154
155
|
* See {@link "./browser".MobileBackend.recordSettled}. */
|
|
155
156
|
recordSettled(action: string, fields: Partial<RecordableFields>, waitedMs: number, error?: string): Promise<number | undefined>;
|
|
156
157
|
}
|
|
157
|
-
/**
|
|
158
|
-
* poll. See {@link getLocatorProbe}. */
|
|
158
|
+
/** Native assertions and recording for a lazy Spectest locator. */
|
|
159
159
|
export interface LocatorProbe {
|
|
160
|
-
/** Human label of the locator (for failure messages). */
|
|
161
160
|
readonly label: string;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* which are about a list of elements and so must not go through the
|
|
167
|
-
* single-element (strict) reads above. */
|
|
168
|
-
allTextContents(): Promise<string[]>;
|
|
169
|
-
allInnerTexts(): Promise<string[]>;
|
|
170
|
-
inputValue(timeout?: number): Promise<string>;
|
|
171
|
-
count(): Promise<number>;
|
|
172
|
-
/** The "did you mean…" block for a locator that matched nothing, or `""`.
|
|
173
|
-
* See locator-hints.ts. */
|
|
161
|
+
/** Resolve the current page once per assertion; Playwright re-resolves the
|
|
162
|
+
* selector on that page throughout the assertion, including navigation. */
|
|
163
|
+
assert(request: LocatorAssertionRequest): Promise<void>;
|
|
164
|
+
/** Supplemental diagnostics, never part of deciding the assertion result. */
|
|
174
165
|
nearMiss(): Promise<string>;
|
|
175
|
-
|
|
176
|
-
isChecked(timeout?: number): Promise<boolean>;
|
|
177
|
-
/** After the silent poll settles, emit the single settled browser step this
|
|
166
|
+
/** After the native assertion settles, emit the single settled browser step this
|
|
178
167
|
* locator's `expect(...)` matcher assertion nests under, and return its seq
|
|
179
168
|
* (provenance + replay seek). `action` is the matcher name, `waitedMs` the
|
|
180
|
-
*
|
|
169
|
+
* assertion time, `error` marks the step failed on a failed matcher. Returns
|
|
181
170
|
* `undefined` when nothing is recording.
|
|
182
171
|
*
|
|
183
172
|
* `opts.reveal` additionally stamps the element's rrweb node id for the
|
|
@@ -189,14 +178,13 @@ export interface LocatorProbe {
|
|
|
189
178
|
reveal?: boolean;
|
|
190
179
|
}): Promise<number | undefined>;
|
|
191
180
|
}
|
|
192
|
-
/**
|
|
193
|
-
*
|
|
194
|
-
* can reach both seams without importing `browser.ts` (and with it
|
|
195
|
-
* playwright) at runtime. */
|
|
181
|
+
/** Native page assertions, kept behind a seam so index.ts does not load
|
|
182
|
+
* Playwright when a project only imports configuration or value assertions. */
|
|
196
183
|
export interface BrowserProbe {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
184
|
+
assertURL(negated: boolean, expected: import("./url-match.js").UrlPattern, opts?: {
|
|
185
|
+
timeout?: number;
|
|
186
|
+
}): Promise<void>;
|
|
187
|
+
/** After the native assertion settles, emit the single settled browser step the
|
|
200
188
|
* matcher's assertion nests under, and return its seq. Same contract as
|
|
201
189
|
* {@link LocatorProbe.settle}. */
|
|
202
190
|
settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
|
|
@@ -207,9 +195,7 @@ export declare function attachBrowserProbe<T extends object>(session: T, probe:
|
|
|
207
195
|
export declare function isBrowserSession(x: unknown): boolean;
|
|
208
196
|
/** The probe branded onto a browser/mobile session by `buildBackend`. */
|
|
209
197
|
export declare function getBrowserProbe(session: unknown): BrowserProbe;
|
|
210
|
-
/** The
|
|
211
|
-
* poll (in index.ts) without pulling playwright types or the backend into
|
|
212
|
-
* that module. */
|
|
198
|
+
/** The assertion/recording seam used by index.ts without loading Playwright. */
|
|
213
199
|
export declare function getLocatorProbe(loc: Locator): LocatorProbe;
|
|
214
200
|
/** Per-session flavor of the `tap()` gesture. Desktop and mobile differ only
|
|
215
201
|
* here; every other locator method is platform-agnostic. */
|
|
@@ -233,9 +219,9 @@ export declare const mobileStrategy: ActionStrategy;
|
|
|
233
219
|
* A lazy reference to element(s), resolved with auto-waiting at the moment an
|
|
234
220
|
* action runs. Mirrors playwright-core's `Locator`: STRICT by default — a
|
|
235
221
|
* locator resolving to multiple elements throws a strict-mode violation at
|
|
236
|
-
* action time rather than silently acting on one.
|
|
237
|
-
*
|
|
238
|
-
*
|
|
222
|
+
* action time rather than silently acting on one. Scope to the intended
|
|
223
|
+
* container, an exact role/name, or a stable test ID. Use positional selection
|
|
224
|
+
* only when order is part of what the test verifies.
|
|
239
225
|
*/
|
|
240
226
|
export interface Locator {
|
|
241
227
|
/** Descendant CSS query, scoped to this locator's subtree. */
|
|
@@ -251,13 +237,15 @@ export interface Locator {
|
|
|
251
237
|
filter(opts: FilterOptions): Locator;
|
|
252
238
|
/** Intersect with another locator (both must match the same element). */
|
|
253
239
|
and(other: Locator): Locator;
|
|
254
|
-
/** Union with another locator (either may match).
|
|
240
|
+
/** Union with another locator (either may match). If both match, a
|
|
241
|
+
* single-element operation still fails strictness. */
|
|
255
242
|
or(other: Locator): Locator;
|
|
256
|
-
/**
|
|
243
|
+
/** First resolved element. Use only when position is the intended contract;
|
|
244
|
+
* does not establish identity or relate this match to another locator. */
|
|
257
245
|
first(): Locator;
|
|
258
|
-
/**
|
|
246
|
+
/** Last resolved element. Prefer a unique identity unless testing order. */
|
|
259
247
|
last(): Locator;
|
|
260
|
-
/**
|
|
248
|
+
/** i-th resolved element (0-based). Prefer a unique identity unless testing order. */
|
|
261
249
|
nth(index: number): Locator;
|
|
262
250
|
click(opts?: ClickOptions): Promise<void>;
|
|
263
251
|
dblclick(opts?: ClickOptions): Promise<void>;
|
|
@@ -312,11 +300,15 @@ export interface Locator {
|
|
|
312
300
|
blur(opts?: TimeoutOption): Promise<void>;
|
|
313
301
|
scrollIntoViewIfNeeded(opts?: TimeoutOption): Promise<void>;
|
|
314
302
|
dragTo(target: Locator, opts?: TimeoutOption): Promise<void>;
|
|
303
|
+
/** One observed value; use `await expect(locator).toHaveText(...)` to
|
|
304
|
+
* retry until expected text appears. Wrapping preserves provenance, not retries. */
|
|
315
305
|
textContent(opts?: TimeoutOption): Promise<Wrapped<string | null>>;
|
|
316
306
|
innerText(opts?: TimeoutOption): Promise<Wrapped<string>>;
|
|
317
307
|
innerHTML(opts?: TimeoutOption): Promise<Wrapped<string>>;
|
|
318
308
|
inputValue(opts?: TimeoutOption): Promise<Wrapped<string>>;
|
|
319
309
|
getAttribute(name: string, opts?: TimeoutOption): Promise<Wrapped<string | null>>;
|
|
310
|
+
/** Immediate visibility observation. Use `await expect(locator).toBeVisible()`
|
|
311
|
+
* for readiness; use `.unwrap()` if branching on this observation. */
|
|
320
312
|
isVisible(): Promise<Wrapped<boolean>>;
|
|
321
313
|
isHidden(): Promise<Wrapped<boolean>>;
|
|
322
314
|
isEnabled(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
|
|
@@ -324,8 +316,9 @@ export interface Locator {
|
|
|
324
316
|
isChecked(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
|
|
325
317
|
isEditable(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
|
|
326
318
|
count(): Promise<Wrapped<number>>;
|
|
327
|
-
/**
|
|
328
|
-
*
|
|
319
|
+
/** One locator per current match, using `nth(i)` (positions, not pinned
|
|
320
|
+
* identities). Does not wait for a list to finish loading; assert its ready
|
|
321
|
+
* state first, and prefer stable IDs if items can move during iteration. */
|
|
329
322
|
all(): Promise<Locator[]>;
|
|
330
323
|
allTextContents(): Promise<Wrapped<string[]>>;
|
|
331
324
|
allInnerTexts(): Promise<Wrapped<string[]>>;
|
package/dist/locator.js
CHANGED
|
@@ -244,9 +244,7 @@ export function isBrowserSession(x) {
|
|
|
244
244
|
export function getBrowserProbe(session) {
|
|
245
245
|
return session[BROWSER_PROBE];
|
|
246
246
|
}
|
|
247
|
-
/** The
|
|
248
|
-
* poll (in index.ts) without pulling playwright types or the backend into
|
|
249
|
-
* that module. */
|
|
247
|
+
/** The assertion/recording seam used by index.ts without loading Playwright. */
|
|
250
248
|
export function getLocatorProbe(loc) {
|
|
251
249
|
return loc[PROBE];
|
|
252
250
|
}
|
|
@@ -476,23 +474,23 @@ export function makeLocator(backend, strategy, chain) {
|
|
|
476
474
|
const rec = { selector: label, target, ...fields };
|
|
477
475
|
return backend.pageOp(action, rec, (page) => readable(page, () => withTarget(page, action, rec, fn)), { wrap: true });
|
|
478
476
|
};
|
|
479
|
-
//
|
|
477
|
+
// Native assertion and recording seam for expect(locator).
|
|
480
478
|
const probe = {
|
|
481
479
|
label,
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
allInnerTexts: () => backend.silentRead((page) => lower(page, chain).allInnerTexts()),
|
|
487
|
-
inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
|
|
488
|
-
count: () => backend.silentRead((page) => lower(page, chain).count()),
|
|
480
|
+
assert: async (request) => {
|
|
481
|
+
const { assertLocator } = await import("./playwright-assertions.js");
|
|
482
|
+
return backend.silentRead((page) => assertLocator(lower(page, chain), request));
|
|
483
|
+
},
|
|
489
484
|
nearMiss: () => backend.silentRead((page) => hintBlock(page)),
|
|
490
|
-
isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
|
|
491
|
-
isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
|
|
492
485
|
settle: async (action, waitedMs, error, opts) => {
|
|
493
486
|
const fields = { selector: label, target };
|
|
494
487
|
if (opts && opts.reveal) {
|
|
495
|
-
|
|
488
|
+
try {
|
|
489
|
+
await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// A page closing after evaluation must not replace the assertion result.
|
|
493
|
+
}
|
|
496
494
|
}
|
|
497
495
|
return backend.recordSettled(action, fields, waitedMs, error);
|
|
498
496
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Locator, Page } from "playwright-core";
|
|
2
|
+
import type { BrowserMatchers, LocatorMatchers } from "./index.js";
|
|
3
|
+
export type LocatorAssertionRequest = {
|
|
4
|
+
[K in keyof LocatorMatchers]: {
|
|
5
|
+
matcher: K;
|
|
6
|
+
args: Parameters<LocatorMatchers[K]>;
|
|
7
|
+
negated: boolean;
|
|
8
|
+
};
|
|
9
|
+
}[keyof LocatorMatchers];
|
|
10
|
+
export declare function assertLocator(locator: Locator, request: LocatorAssertionRequest): Promise<void>;
|
|
11
|
+
export declare function assertURL(page: Page, negated: boolean, ...[expected, options]: Parameters<BrowserMatchers["toHaveURL"]>): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
|
|
5
|
+
import { globToRegExp } from "./url-match.js";
|
|
6
|
+
const browserExpect = expect.configure({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
7
|
+
export async function assertLocator(locator, request) {
|
|
8
|
+
const positive = browserExpect(locator);
|
|
9
|
+
const assertion = request.negated ? positive.not : positive;
|
|
10
|
+
switch (request.matcher) {
|
|
11
|
+
case "toBeVisible": return assertion.toBeVisible(...request.args);
|
|
12
|
+
case "toBeHidden": return assertion.toBeHidden(...request.args);
|
|
13
|
+
case "toHaveText": return assertion.toHaveText(...request.args);
|
|
14
|
+
case "toContainText": return assertion.toContainText(...request.args);
|
|
15
|
+
case "toHaveValue": return assertion.toHaveValue(...request.args);
|
|
16
|
+
case "toHaveCount": return assertion.toHaveCount(...request.args);
|
|
17
|
+
case "toBeEnabled": return assertion.toBeEnabled(...request.args);
|
|
18
|
+
case "toBeDisabled": return assertion.toBeDisabled(...request.args);
|
|
19
|
+
case "toBeChecked": return assertion.toBeChecked(...request.args);
|
|
20
|
+
default: {
|
|
21
|
+
const unsupported = request;
|
|
22
|
+
throw new Error(`Unsupported locator assertion: ${String(unsupported)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function assertURL(page, negated, ...[expected, options]) {
|
|
27
|
+
const positive = browserExpect(page);
|
|
28
|
+
// Spectest has always accepted URL globs. Convert just that syntax; use
|
|
29
|
+
// native URL assertions for evaluation (including predicates and .not).
|
|
30
|
+
const pattern = typeof expected === "string" ? globToRegExp(expected) : expected;
|
|
31
|
+
await (negated ? positive.not : positive).toHaveURL(pattern, options);
|
|
32
|
+
}
|
package/dist/text-match.d.ts
CHANGED
|
@@ -7,16 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function normalizeWhiteSpace(text: string): string;
|
|
9
9
|
export interface TextMatchOptions {
|
|
10
|
-
/**
|
|
11
|
-
* flag, as playwright's own `ignoreCase` does. */
|
|
10
|
+
/** Override case sensitivity, including a RegExp's `i` flag. */
|
|
12
11
|
ignoreCase?: boolean;
|
|
13
12
|
}
|
|
14
|
-
/** The element's text equals `expected`, with whitespace normalized on both
|
|
15
|
-
* sides. A RegExp is tested against the normalized text. */
|
|
16
|
-
export declare function matchesText(actual: string, expected: string | RegExp, opts?: TextMatchOptions): boolean;
|
|
17
|
-
/** The element's text contains `expected`, with whitespace normalized on both
|
|
18
|
-
* sides. A RegExp is tested against the normalized text. */
|
|
19
|
-
export declare function containsText(actual: string, expected: string | RegExp, opts?: TextMatchOptions): boolean;
|
|
20
13
|
/** `" "` for an invisible character, the character itself otherwise.
|
|
21
14
|
* Applied to an ALREADY `JSON.stringify`-ed string, so the result stays a
|
|
22
15
|
* valid JSON string literal. */
|
|
@@ -31,9 +24,3 @@ export declare function escapeInvisible(s: string): string;
|
|
|
31
24
|
* `"contains"` for the substring ones.
|
|
32
25
|
*/
|
|
33
26
|
export declare function textDifferenceNote(actual: unknown, expected: unknown, mode: "equal" | "contains"): string;
|
|
34
|
-
/** Every element's text equals the item at its position, and there are
|
|
35
|
-
* exactly as many elements as items. */
|
|
36
|
-
export declare function matchesTextArray(actual: string[], expected: (string | RegExp)[], opts?: TextMatchOptions): boolean;
|
|
37
|
-
/** The elements' texts contain every item, in order. Extra elements between
|
|
38
|
-
* the matches are allowed. */
|
|
39
|
-
export declare function containsTextArray(actual: string[], expected: (string | RegExp)[], opts?: TextMatchOptions): boolean;
|