@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/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, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
39
- import { classifyLocatorFailure, formatWaited, locatorFailureMessage, } from "./locator-errors.js";
40
- import { containsText, containsTextArray, escapeInvisible, matchesText, matchesTextArray, textDifferenceNote, } from "./text-match.js";
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
- /** Poll interval for locator matchers. */
694
- const LOCATOR_POLL_MS = 50;
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
- // Poll `check` (a silent, non-recorded read) until the desired condition
703
- // holds or the deadline elapses, then emit ONE settled browser step (the
704
- // locator label + replay seek point) and record ONE assertion nested under
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
- const settleOpts = { reveal: !expectsGone };
725
- // "That element is not there" said plainly, or `undefined` when the
726
- // element IS there and the matcher's own description is the better
727
- // sentence.
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: (opts) => run("toBeVisible", opts?.timeout, async () => {
853
- const v = await probe.isVisible();
854
- return { satisfied: v, actual: v };
855
- }, () => `expected ${probe.label}${not} to be visible`),
856
- toBeHidden: (opts) => run("toBeHidden", opts?.timeout, async () => {
857
- const v = await probe.isVisible();
858
- return { satisfied: !v, actual: v };
859
- }, () => `expected ${probe.label}${not} to be hidden`),
860
- toHaveText: (expected, opts) => run("toHaveText", opts?.timeout, async () => {
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): auto-retrying session matchers ───────────────────────
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: async (expected, opts) => {
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
  /**
@@ -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
- " — narrow it with .first(), .nth(i), .filter({ hasText }) or a more specific query";
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 poll path for `expect(locator)` matchers, which
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
- /** Silent (non-recorded) reads a locator exposes for `expect(...)` matchers to
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
- isVisible(): Promise<boolean>;
163
- textContent(timeout?: number): Promise<string | null>;
164
- innerText(timeout?: number): Promise<string>;
165
- /** Every match's text — the array forms of `toHaveText`/`toContainText`,
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
- isEnabled(timeout?: number): Promise<boolean>;
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
- * poll time, `error` marks the step failed on a timed-out matcher. Returns
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
- /** Silent (non-recorded) reads an `expect(browser)` matcher polls — the
193
- * session twin of {@link LocatorProbe}. Lives here, next to it, so `index.ts`
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
- /** Current main-frame URL. Synchronous, unrecorded, no rrweb drain. */
198
- url(): string;
199
- /** After the silent poll settles, emit the single settled browser step the
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 silent-read probe for a locator — the seam `expect(locator)` matchers
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. Narrow with `exact: true`,
237
- * a testid, `filter(...)`, or the explicit `first()`/`last()`/`nth(i)`
238
- * opt-outs.
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
- /** Explicit strict-mode opt-out: the first resolved element. */
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
- /** Explicit strict-mode opt-out: the last resolved element. */
246
+ /** Last resolved element. Prefer a unique identity unless testing order. */
259
247
  last(): Locator;
260
- /** Explicit strict-mode opt-out: the i-th resolved element (0-based). */
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
- /** Resolve to one locator per current match (handle-free — each is pinned
328
- * with `nth(i)`). One recorded event. */
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 silent-read probe for a locator — the seam `expect(locator)` matchers
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
- // Silent, non-recorded reads for expect(locator) matchers to poll.
477
+ // Native assertion and recording seam for expect(locator).
480
478
  const probe = {
481
479
  label,
482
- isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
483
- textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
484
- innerText: (timeout) => backend.silentRead((page) => lower(page, chain).innerText({ timeout })),
485
- allTextContents: () => backend.silentRead((page) => lower(page, chain).allTextContents()),
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
- await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
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
+ }
@@ -7,16 +7,9 @@
7
7
  */
8
8
  export declare function normalizeWhiteSpace(text: string): string;
9
9
  export interface TextMatchOptions {
10
- /** Compare without regard to letter case. On a RegExp this adds the `i`
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;