@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/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { LocatorAssertionRequest } from "./playwright-assertions.js";
|
|
1
3
|
import type { InjectRule, SignConfig } from "./fake-upstream.js";
|
|
2
4
|
export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
|
|
3
5
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
@@ -134,26 +136,16 @@ import {
|
|
|
134
136
|
getLocatorProbe,
|
|
135
137
|
isBrowserSession,
|
|
136
138
|
getBrowserProbe,
|
|
137
|
-
DEFAULT_ACTION_TIMEOUT_MS,
|
|
138
139
|
} from "./locator.js";
|
|
139
140
|
import {
|
|
140
|
-
classifyLocatorFailure,
|
|
141
|
-
formatWaited,
|
|
142
|
-
locatorFailureMessage,
|
|
143
|
-
} from "./locator-errors.js";
|
|
144
|
-
import {
|
|
145
|
-
containsText,
|
|
146
|
-
containsTextArray,
|
|
147
141
|
escapeInvisible,
|
|
148
|
-
matchesText,
|
|
149
|
-
matchesTextArray,
|
|
150
142
|
textDifferenceNote,
|
|
151
143
|
type TextMatchOptions,
|
|
152
144
|
} from "./text-match.js";
|
|
153
145
|
|
|
154
146
|
export type { UrlPattern } from "./url-match.js";
|
|
155
147
|
import type { UrlPattern } from "./url-match.js";
|
|
156
|
-
import { describeUrlPattern
|
|
148
|
+
import { describeUrlPattern } from "./url-match.js";
|
|
157
149
|
|
|
158
150
|
// Mobile (Expo / React Native Web) surface: a phone-emulated session driven
|
|
159
151
|
// with the same locators + touch gestures, replayed inside a phone bezel.
|
|
@@ -547,7 +539,8 @@ export interface SpectestContext<
|
|
|
547
539
|
*/
|
|
548
540
|
readonly fakes: FakeHandlesFor<F>;
|
|
549
541
|
/**
|
|
550
|
-
* Poll a predicate until it returns a
|
|
542
|
+
* Poll a predicate until it returns a value other than null, undefined,
|
|
543
|
+
* or false, then return that
|
|
551
544
|
* value. In a test it records one `wait` event for the whole loop (with
|
|
552
545
|
* attempt count, total duration, and the description) instead of one
|
|
553
546
|
* event per probe — useful for "wait until pod Running"-style checks
|
|
@@ -555,19 +548,20 @@ export interface SpectestContext<
|
|
|
555
548
|
* timeline; it's just the loop you'd otherwise hand-roll.
|
|
556
549
|
*
|
|
557
550
|
* - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
|
|
558
|
-
* `intervalMs` and try again.
|
|
551
|
+
* `intervalMs` and try again. A directly returned wrapped value is
|
|
552
|
+
* checked using its underlying value. Use `.unwrap()` when branching
|
|
553
|
+
* on wrapped primitives inside `fn`; wrapper objects are truthy.
|
|
559
554
|
* - Anything else is the success value and is returned, tagged with
|
|
560
555
|
* the wait event's seq. Downstream `expect(...)` on it links to
|
|
561
556
|
* the wait (one logical step), not to N suppressed HTTP calls.
|
|
562
|
-
* -
|
|
563
|
-
*
|
|
564
|
-
* abort.
|
|
557
|
+
* - Instrumented fetch transport failures are retried. Other throws
|
|
558
|
+
* from `fn` propagate immediately; the wait event records the abort.
|
|
565
559
|
* - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
|
|
566
560
|
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
561
|
+
* The winning attempt's events, or the final failed attempt's events,
|
|
562
|
+
* are retained beneath the wait; earlier attempts are discarded. Use
|
|
563
|
+
* `ctx.poll` for read-only observation of the exact required state.
|
|
564
|
+
* Perform mutations once outside the poll so retries cannot repeat them.
|
|
571
565
|
*/
|
|
572
566
|
poll<T>(
|
|
573
567
|
description: string,
|
|
@@ -2951,9 +2945,10 @@ export interface TextMatcherOptions extends TextMatchOptions {
|
|
|
2951
2945
|
|
|
2952
2946
|
/**
|
|
2953
2947
|
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
2954
|
-
* `expect(locator)` matchers.
|
|
2955
|
-
* deadline elapses (default
|
|
2956
|
-
*
|
|
2948
|
+
* `expect(locator)` matchers. Playwright re-resolves the locator until it
|
|
2949
|
+
* passes or a deadline elapses (default 10 s, `{ timeout }` overrides; 0
|
|
2950
|
+
* disables the assertion timeout). Records an assertion event just like a
|
|
2951
|
+
* value `expect`. `await` them — they are async.
|
|
2957
2952
|
*/
|
|
2958
2953
|
export interface LocatorMatchers {
|
|
2959
2954
|
/** The element is present and visible. */
|
|
@@ -2963,8 +2958,9 @@ export interface LocatorMatchers {
|
|
|
2963
2958
|
/**
|
|
2964
2959
|
* The element's text equals `expected`, or matches it when it is a RegExp.
|
|
2965
2960
|
*
|
|
2966
|
-
* **
|
|
2967
|
-
*
|
|
2961
|
+
* **String expectations normalize whitespace on both sides**, as Playwright
|
|
2962
|
+
* does. Regex expectations test the original text. String comparisons trim
|
|
2963
|
+
* the text and collapse every run of whitespace to one space.
|
|
2968
2964
|
* So a typed space matches the no-break space (U+00A0) that
|
|
2969
2965
|
* `Intl.NumberFormat` puts between thousands, and a value wrapped across
|
|
2970
2966
|
* two lines in the markup matches the one-line string you wrote.
|
|
@@ -2978,7 +2974,8 @@ export interface LocatorMatchers {
|
|
|
2978
2974
|
): Promise<void>;
|
|
2979
2975
|
/**
|
|
2980
2976
|
* The element's text contains `expected` — a substring, or a RegExp tested
|
|
2981
|
-
* against the text.
|
|
2977
|
+
* against the text. Strings normalize whitespace as in {@link toHaveText};
|
|
2978
|
+
* regexes test the original text.
|
|
2982
2979
|
*
|
|
2983
2980
|
* An **array** asserts that the matched elements contain these texts in
|
|
2984
2981
|
* order; unlike `toHaveText` extra elements between them are allowed.
|
|
@@ -3004,8 +3001,8 @@ export interface LocatorAssertion extends LocatorMatchers {
|
|
|
3004
3001
|
/**
|
|
3005
3002
|
* Auto-retrying assertions for a browser/mobile **session** — Playwright's
|
|
3006
3003
|
* `expect(page)` matchers. Like the locator ones, they poll until the
|
|
3007
|
-
* condition holds or the deadline elapses (default
|
|
3008
|
-
* assertion under a settled browser step.
|
|
3004
|
+
* condition holds or the deadline elapses (default 10 s; 0 disables the
|
|
3005
|
+
* assertion timeout) and record one assertion under a settled browser step.
|
|
3009
3006
|
*/
|
|
3010
3007
|
export interface BrowserMatchers {
|
|
3011
3008
|
/**
|
|
@@ -3063,8 +3060,57 @@ function expectValue(actual: Provenanced, message?: string): Expectation {
|
|
|
3063
3060
|
|
|
3064
3061
|
// ── expect(locator): auto-retrying web-first matchers ─────────────────────
|
|
3065
3062
|
|
|
3066
|
-
/**
|
|
3067
|
-
|
|
3063
|
+
/** Keep the public error type and recording, but never decide whether a
|
|
3064
|
+
* browser condition passed here. That belongs entirely to Playwright. */
|
|
3065
|
+
async function recordWebAssertion(
|
|
3066
|
+
matcher: string,
|
|
3067
|
+
expected: unknown,
|
|
3068
|
+
negated: boolean,
|
|
3069
|
+
message: string | undefined,
|
|
3070
|
+
assert: () => Promise<void>,
|
|
3071
|
+
settle: (durationMs: number, error?: string) => Promise<number | undefined>,
|
|
3072
|
+
diagnostics?: (error: string) => Promise<string>,
|
|
3073
|
+
): Promise<void> {
|
|
3074
|
+
const started = Date.now();
|
|
3075
|
+
let failure: ExpectationError | undefined;
|
|
3076
|
+
let actual: unknown;
|
|
3077
|
+
try {
|
|
3078
|
+
await assert();
|
|
3079
|
+
} catch (cause) {
|
|
3080
|
+
// matcherResult is diagnostic metadata, not an API we use for evaluation.
|
|
3081
|
+
// Terminal errors need not carry it. Never re-read the page to manufacture
|
|
3082
|
+
// an observed value: it may already differ from the assertion's result.
|
|
3083
|
+
actual = (cause as { matcherResult?: { actual?: unknown } } | null)?.matcherResult?.actual;
|
|
3084
|
+
let error = stripVTControlCharacters(cause instanceof Error ? cause.message : String(cause));
|
|
3085
|
+
if (typeof actual === "string" && typeof expected === "string") {
|
|
3086
|
+
error += textDifferenceNote(actual, expected, matcher === "toContainText" ? "contains" : "equal");
|
|
3087
|
+
}
|
|
3088
|
+
if (diagnostics) {
|
|
3089
|
+
try { error += await diagnostics(error); } catch { /* best effort */ }
|
|
3090
|
+
}
|
|
3091
|
+
failure = new ExpectationError(error);
|
|
3092
|
+
failure.cause = cause;
|
|
3093
|
+
}
|
|
3094
|
+
const sourceSeq = await settle(Date.now() - started, failure?.message);
|
|
3095
|
+
recordAssertion({
|
|
3096
|
+
matcher,
|
|
3097
|
+
negated,
|
|
3098
|
+
passed: !failure,
|
|
3099
|
+
// The public Playwright API returns void on success. Omit actual rather
|
|
3100
|
+
// than recording a later read as though it were the value that passed.
|
|
3101
|
+
actual: actual === undefined ? undefined : safeSerialize(actual),
|
|
3102
|
+
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
3103
|
+
error: failure?.message,
|
|
3104
|
+
message,
|
|
3105
|
+
sourceSeq,
|
|
3106
|
+
});
|
|
3107
|
+
if (failure) throw failure;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function assertionExpected(value: unknown): unknown {
|
|
3111
|
+
return value instanceof RegExp ? String(value)
|
|
3112
|
+
: Array.isArray(value) ? value.map(assertionExpected) : value;
|
|
3113
|
+
}
|
|
3068
3114
|
|
|
3069
3115
|
function buildLocatorAssertion(loc: Locator, message?: string): LocatorAssertion {
|
|
3070
3116
|
return Object.assign(buildLocatorMatchers(loc, false, message), {
|
|
@@ -3072,294 +3118,38 @@ function buildLocatorAssertion(loc: Locator, message?: string): LocatorAssertion
|
|
|
3072
3118
|
});
|
|
3073
3119
|
}
|
|
3074
3120
|
|
|
3075
|
-
function buildLocatorMatchers(
|
|
3076
|
-
loc: Locator,
|
|
3077
|
-
negated: boolean,
|
|
3078
|
-
message?: string,
|
|
3079
|
-
): LocatorMatchers {
|
|
3121
|
+
function buildLocatorMatchers(loc: Locator, negated: boolean, message?: string): LocatorMatchers {
|
|
3080
3122
|
const probe = getLocatorProbe(loc);
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
// locator label + replay seek point) and record ONE assertion nested under
|
|
3085
|
-
// it via `sourceSeq` — so a web-first `expect(locator)` assertion carries
|
|
3086
|
-
// the same provenance a value assertion does (`expect(await loc.isVisible())`
|
|
3087
|
-
// renders identically). Without the anchor step these assertions floated as
|
|
3088
|
-
// disconnected top-level "value ✓" rows with no element and no replay seek.
|
|
3089
|
-
// `check` returns whether the base condition is satisfied plus the observed
|
|
3090
|
-
// value for the timeline.
|
|
3091
|
-
const run = async (
|
|
3092
|
-
matcher: string,
|
|
3093
|
-
timeout: number | undefined,
|
|
3094
|
-
check: () => Promise<{ satisfied: boolean; actual: unknown }>,
|
|
3095
|
-
describe: (actual: unknown) => string,
|
|
3096
|
-
expected?: unknown,
|
|
3097
|
-
): Promise<void> => {
|
|
3098
|
-
const started = Date.now();
|
|
3099
|
-
const budget = timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
3100
|
-
const deadline = started + budget;
|
|
3101
|
-
let actual: unknown;
|
|
3102
|
-
// Whether this matcher's subject is meant to be on screen, i.e. whether
|
|
3103
|
-
// the step should carry a reveal target for the replay (see
|
|
3104
|
-
// `LocatorProbe.settle`). Only the visibility matchers flip with `negated`
|
|
3105
|
-
// — `not.toHaveText(...)` is still an element the reader should be looking
|
|
3106
|
-
// at — and `toHaveCount(0)` is the third way of saying "expect nothing".
|
|
3123
|
+
const run = (request: LocatorAssertionRequest): Promise<void> => {
|
|
3124
|
+
const { matcher, args } = request;
|
|
3125
|
+
const expected = matcher.startsWith("toHave") || matcher === "toContainText" ? args[0] : undefined;
|
|
3107
3126
|
const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
|
|
3108
3127
|
const expectsGone =
|
|
3109
3128
|
(visibility && (matcher === "toBeHidden") !== negated) ||
|
|
3110
|
-
(matcher === "toHaveCount" && expected === 0);
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
// not — `toBeVisible()` on a selector that matches nothing and one that
|
|
3120
|
-
// matches a hidden element fail identically — so the count is read once,
|
|
3121
|
-
// at failure time only. `toHaveCount` is left alone: "expected count 2,
|
|
3122
|
-
// got 0" already says it, and better.
|
|
3123
|
-
//
|
|
3124
|
-
// Either way the message ends with the near-miss block — what the page
|
|
3125
|
-
// holds that is close to what was asked for (see locator-hints.ts) — so
|
|
3126
|
-
// the author does not have to run again to find out.
|
|
3127
|
-
const nearMiss = async (): Promise<string> => {
|
|
3128
|
-
try {
|
|
3129
|
-
return await probe.nearMiss();
|
|
3130
|
-
} catch {
|
|
3131
|
-
return "";
|
|
3132
|
-
}
|
|
3133
|
-
};
|
|
3134
|
-
const elementFailure = async (err: unknown): Promise<string | undefined> => {
|
|
3135
|
-
if (err !== undefined) {
|
|
3136
|
-
const failure = classifyLocatorFailure(probe.label, err);
|
|
3137
|
-
if (!failure) return undefined;
|
|
3138
|
-
return failure.kind === "no-match"
|
|
3139
|
-
? failure.message + (await nearMiss())
|
|
3140
|
-
: failure.message;
|
|
3141
|
-
}
|
|
3142
|
-
if (expectsGone || matcher === "toHaveCount") return undefined;
|
|
3143
|
-
try {
|
|
3144
|
-
if ((await probe.count()) === 0) {
|
|
3145
|
-
return (
|
|
3146
|
-
`No element matches ${probe.label} (waited ${formatWaited(budget)})` +
|
|
3147
|
-
(await nearMiss())
|
|
3148
|
-
);
|
|
3149
|
-
}
|
|
3150
|
-
} catch {
|
|
3151
|
-
/* The page is gone or the chain is invalid — the matcher's own
|
|
3152
|
-
description still says what was expected. */
|
|
3153
|
-
}
|
|
3154
|
-
return undefined;
|
|
3155
|
-
};
|
|
3156
|
-
// The last error a probe threw, kept for the failure message: a matcher
|
|
3157
|
-
// whose read waits for the element (textContent, inputValue, isEnabled …)
|
|
3158
|
-
// reports a missing element as a playwright timeout, and that — not
|
|
3159
|
-
// "got <error: Timeout 10000ms exceeded …>" — is the sentence to fail with.
|
|
3160
|
-
let probeError: unknown;
|
|
3161
|
-
for (;;) {
|
|
3162
|
-
let satisfied: boolean;
|
|
3163
|
-
try {
|
|
3164
|
-
const r = await check();
|
|
3165
|
-
satisfied = r.satisfied;
|
|
3166
|
-
actual = r.actual;
|
|
3167
|
-
probeError = undefined;
|
|
3168
|
-
} catch (err) {
|
|
3169
|
-
probeError = err;
|
|
3170
|
-
if (Date.now() < deadline) {
|
|
3171
|
-
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
3172
|
-
continue;
|
|
3173
|
-
}
|
|
3174
|
-
satisfied = false;
|
|
3175
|
-
// The recorded "actual" is what the timeline shows next to the
|
|
3176
|
-
// matcher, so it gets the readable summary too — never the call-log
|
|
3177
|
-
// wall a playwright timeout carries.
|
|
3178
|
-
actual =
|
|
3179
|
-
locatorFailureMessage(probe.label, err) ??
|
|
3180
|
-
`<error: ${(err as Error)?.message ?? String(err)}>`;
|
|
3181
|
-
}
|
|
3182
|
-
const passed = satisfied !== negated;
|
|
3183
|
-
if (passed) {
|
|
3184
|
-
const sourceSeq = await probe.settle(matcher, Date.now() - started, undefined, settleOpts);
|
|
3185
|
-
recordAssertion({
|
|
3186
|
-
matcher,
|
|
3187
|
-
negated,
|
|
3188
|
-
passed: true,
|
|
3189
|
-
actual: safeSerialize(actual),
|
|
3190
|
-
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
3191
|
-
message,
|
|
3192
|
-
sourceSeq,
|
|
3193
|
-
});
|
|
3194
|
-
return;
|
|
3195
|
-
}
|
|
3196
|
-
if (Date.now() >= deadline) {
|
|
3197
|
-
// The deadline, not the stopwatch: every one of these polled until it
|
|
3198
|
-
// ran out, and "(waited 10s)" is both what the author set and the same
|
|
3199
|
-
// number twice in a row — a measured 9_987ms is neither.
|
|
3200
|
-
const msg = (await elementFailure(probeError)) ??
|
|
3201
|
-
`${describe(actual)} (waited ${formatWaited(budget)})`;
|
|
3202
|
-
const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
|
|
3203
|
-
recordAssertion({
|
|
3204
|
-
matcher,
|
|
3205
|
-
negated,
|
|
3206
|
-
passed: false,
|
|
3207
|
-
actual: safeSerialize(actual),
|
|
3208
|
-
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
3209
|
-
error: msg,
|
|
3210
|
-
message,
|
|
3211
|
-
sourceSeq,
|
|
3212
|
-
});
|
|
3213
|
-
throw new ExpectationError(msg);
|
|
3214
|
-
}
|
|
3215
|
-
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
3216
|
-
}
|
|
3129
|
+
(matcher === "toHaveCount" && expected === 0 && !negated);
|
|
3130
|
+
return recordWebAssertion(
|
|
3131
|
+
matcher, assertionExpected(expected), negated, message,
|
|
3132
|
+
() => probe.assert(request),
|
|
3133
|
+
(duration, error) => probe.settle(matcher, duration, error, { reveal: !expectsGone }),
|
|
3134
|
+
// Preserve native diagnostics, adding suggestions only for missing
|
|
3135
|
+
// elements. A mismatch/strictness/closed-page failure needs no new read.
|
|
3136
|
+
(error) => error.includes("element(s) not found") ? probe.nearMiss() : Promise.resolve(""),
|
|
3137
|
+
);
|
|
3217
3138
|
};
|
|
3218
|
-
|
|
3219
|
-
const not = negated ? " not" : "";
|
|
3220
|
-
// The element's text, read the way the caller asked for it. `useInnerText`
|
|
3221
|
-
// is playwright's option and means the same here: what the page renders,
|
|
3222
|
-
// rather than every character in the subtree.
|
|
3223
|
-
const readText = async (opts?: TextMatcherOptions): Promise<string> =>
|
|
3224
|
-
opts?.useInnerText
|
|
3225
|
-
? await probe.innerText(opts.timeout)
|
|
3226
|
-
: ((await probe.textContent(opts?.timeout)) ?? "");
|
|
3227
|
-
const readTexts = async (opts?: TextMatcherOptions): Promise<string[]> =>
|
|
3228
|
-
opts?.useInnerText ? await probe.allInnerTexts() : await probe.allTextContents();
|
|
3229
|
-
const isArrayExpectation = (
|
|
3230
|
-
v: string | RegExp | Array<string | RegExp>,
|
|
3231
|
-
): v is Array<string | RegExp> => Array.isArray(v);
|
|
3232
|
-
/** Expected values render as themselves; a RegExp renders as its source. */
|
|
3233
|
-
const fmtExpected = (v: string | RegExp | Array<string | RegExp>): string =>
|
|
3234
|
-
v instanceof RegExp
|
|
3235
|
-
? String(v)
|
|
3236
|
-
: Array.isArray(v)
|
|
3237
|
-
? `[${v.map(fmtExpected).join(", ")}]`
|
|
3238
|
-
: fmt(v);
|
|
3239
|
-
/** What goes on the assertion event: a RegExp cannot be serialized. */
|
|
3240
|
-
const expectedValue = (
|
|
3241
|
-
v: string | RegExp | Array<string | RegExp>,
|
|
3242
|
-
): unknown => (v instanceof RegExp ? String(v) : Array.isArray(v) ? v.map(expectedValue) : v);
|
|
3243
|
-
|
|
3244
3139
|
return {
|
|
3245
|
-
toBeVisible: (
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
),
|
|
3255
|
-
toBeHidden: (opts) =>
|
|
3256
|
-
run(
|
|
3257
|
-
"toBeHidden",
|
|
3258
|
-
opts?.timeout,
|
|
3259
|
-
async () => {
|
|
3260
|
-
const v = await probe.isVisible();
|
|
3261
|
-
return { satisfied: !v, actual: v };
|
|
3262
|
-
},
|
|
3263
|
-
() => `expected ${probe.label}${not} to be hidden`,
|
|
3264
|
-
),
|
|
3265
|
-
toHaveText: (expected, opts) =>
|
|
3266
|
-
run(
|
|
3267
|
-
"toHaveText",
|
|
3268
|
-
opts?.timeout,
|
|
3269
|
-
async () => {
|
|
3270
|
-
if (isArrayExpectation(expected)) {
|
|
3271
|
-
const texts = await readTexts(opts);
|
|
3272
|
-
return { satisfied: matchesTextArray(texts, expected, opts), actual: texts };
|
|
3273
|
-
}
|
|
3274
|
-
const t = await readText(opts);
|
|
3275
|
-
return { satisfied: matchesText(t, expected, opts), actual: t };
|
|
3276
|
-
},
|
|
3277
|
-
(a) =>
|
|
3278
|
-
`expected ${probe.label}${not} to have text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
3279
|
-
textDifferenceNote(a, expected, "equal"),
|
|
3280
|
-
expectedValue(expected),
|
|
3281
|
-
),
|
|
3282
|
-
toContainText: (expected, opts) =>
|
|
3283
|
-
run(
|
|
3284
|
-
"toContainText",
|
|
3285
|
-
opts?.timeout,
|
|
3286
|
-
async () => {
|
|
3287
|
-
if (isArrayExpectation(expected)) {
|
|
3288
|
-
const texts = await readTexts(opts);
|
|
3289
|
-
return { satisfied: containsTextArray(texts, expected, opts), actual: texts };
|
|
3290
|
-
}
|
|
3291
|
-
const t = await readText(opts);
|
|
3292
|
-
return { satisfied: containsText(t, expected, opts), actual: t };
|
|
3293
|
-
},
|
|
3294
|
-
(a) =>
|
|
3295
|
-
`expected ${probe.label}${not} to contain text ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
3296
|
-
textDifferenceNote(a, expected, "contains"),
|
|
3297
|
-
expectedValue(expected),
|
|
3298
|
-
),
|
|
3299
|
-
// Deliberately NOT whitespace-normalized: an input's value is data the
|
|
3300
|
-
// user typed or the app set, not rendered text, and playwright compares
|
|
3301
|
-
// it exactly for the same reason. When that exactness is what failed,
|
|
3302
|
-
// `textDifferenceNote` says so instead of leaving two identical-looking
|
|
3303
|
-
// strings on screen.
|
|
3304
|
-
toHaveValue: (expected, opts) =>
|
|
3305
|
-
run(
|
|
3306
|
-
"toHaveValue",
|
|
3307
|
-
opts?.timeout,
|
|
3308
|
-
async () => {
|
|
3309
|
-
const v = await probe.inputValue(opts?.timeout);
|
|
3310
|
-
const satisfied = expected instanceof RegExp ? expected.test(v) : v === expected;
|
|
3311
|
-
return { satisfied, actual: v };
|
|
3312
|
-
},
|
|
3313
|
-
(a) =>
|
|
3314
|
-
`expected ${probe.label}${not} to have value ${fmtExpected(expected)}, got ${fmt(a)}` +
|
|
3315
|
-
textDifferenceNote(a, expected, "equal"),
|
|
3316
|
-
expectedValue(expected),
|
|
3317
|
-
),
|
|
3318
|
-
toHaveCount: (expected, opts) =>
|
|
3319
|
-
run(
|
|
3320
|
-
"toHaveCount",
|
|
3321
|
-
opts?.timeout,
|
|
3322
|
-
async () => {
|
|
3323
|
-
const c = await probe.count();
|
|
3324
|
-
return { satisfied: c === expected, actual: c };
|
|
3325
|
-
},
|
|
3326
|
-
(a) => `expected ${probe.label}${not} to have count ${expected}, got ${fmt(a)}`,
|
|
3327
|
-
expected,
|
|
3328
|
-
),
|
|
3329
|
-
toBeEnabled: (opts) =>
|
|
3330
|
-
run(
|
|
3331
|
-
"toBeEnabled",
|
|
3332
|
-
opts?.timeout,
|
|
3333
|
-
async () => {
|
|
3334
|
-
const v = await probe.isEnabled(opts?.timeout);
|
|
3335
|
-
return { satisfied: v, actual: v };
|
|
3336
|
-
},
|
|
3337
|
-
() => `expected ${probe.label}${not} to be enabled`,
|
|
3338
|
-
),
|
|
3339
|
-
toBeDisabled: (opts) =>
|
|
3340
|
-
run(
|
|
3341
|
-
"toBeDisabled",
|
|
3342
|
-
opts?.timeout,
|
|
3343
|
-
async () => {
|
|
3344
|
-
const v = await probe.isEnabled(opts?.timeout);
|
|
3345
|
-
return { satisfied: !v, actual: v };
|
|
3346
|
-
},
|
|
3347
|
-
() => `expected ${probe.label}${not} to be disabled`,
|
|
3348
|
-
),
|
|
3349
|
-
toBeChecked: (opts) =>
|
|
3350
|
-
run(
|
|
3351
|
-
"toBeChecked",
|
|
3352
|
-
opts?.timeout,
|
|
3353
|
-
async () => {
|
|
3354
|
-
const v = await probe.isChecked(opts?.timeout);
|
|
3355
|
-
return { satisfied: v, actual: v };
|
|
3356
|
-
},
|
|
3357
|
-
() => `expected ${probe.label}${not} to be checked`,
|
|
3358
|
-
),
|
|
3140
|
+
toBeVisible: (...args) => run({ matcher: "toBeVisible", args, negated }),
|
|
3141
|
+
toBeHidden: (...args) => run({ matcher: "toBeHidden", args, negated }),
|
|
3142
|
+
toHaveText: (...args) => run({ matcher: "toHaveText", args, negated }),
|
|
3143
|
+
toContainText: (...args) => run({ matcher: "toContainText", args, negated }),
|
|
3144
|
+
toHaveValue: (...args) => run({ matcher: "toHaveValue", args, negated }),
|
|
3145
|
+
toHaveCount: (...args) => run({ matcher: "toHaveCount", args, negated }),
|
|
3146
|
+
toBeEnabled: (...args) => run({ matcher: "toBeEnabled", args, negated }),
|
|
3147
|
+
toBeDisabled: (...args) => run({ matcher: "toBeDisabled", args, negated }),
|
|
3148
|
+
toBeChecked: (...args) => run({ matcher: "toBeChecked", args, negated }),
|
|
3359
3149
|
};
|
|
3360
3150
|
}
|
|
3361
3151
|
|
|
3362
|
-
// ── expect(browser):
|
|
3152
|
+
// ── expect(browser): native page assertions ─────────────────────────────
|
|
3363
3153
|
|
|
3364
3154
|
function buildBrowserAssertion(session: unknown, message?: string): BrowserAssertion {
|
|
3365
3155
|
return Object.assign(buildBrowserMatchers(session, false, message), {
|
|
@@ -3367,55 +3157,14 @@ function buildBrowserAssertion(session: unknown, message?: string): BrowserAsser
|
|
|
3367
3157
|
});
|
|
3368
3158
|
}
|
|
3369
3159
|
|
|
3370
|
-
function buildBrowserMatchers(
|
|
3371
|
-
session: unknown,
|
|
3372
|
-
negated: boolean,
|
|
3373
|
-
message?: string,
|
|
3374
|
-
): BrowserMatchers {
|
|
3160
|
+
function buildBrowserMatchers(session: unknown, negated: boolean, message?: string): BrowserMatchers {
|
|
3375
3161
|
const probe = getBrowserProbe(session);
|
|
3376
|
-
const not = negated ? " not" : "";
|
|
3377
|
-
|
|
3378
3162
|
return {
|
|
3379
|
-
toHaveURL:
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
// the locator matchers.
|
|
3385
|
-
const label = describeUrlPattern(expected);
|
|
3386
|
-
for (;;) {
|
|
3387
|
-
const actual = probe.url();
|
|
3388
|
-
if (matchesUrl(actual, expected) !== negated) {
|
|
3389
|
-
const sourceSeq = await probe.settle("toHaveURL", Date.now() - started);
|
|
3390
|
-
recordAssertion({
|
|
3391
|
-
matcher: "toHaveURL",
|
|
3392
|
-
negated,
|
|
3393
|
-
passed: true,
|
|
3394
|
-
actual: safeSerialize(actual),
|
|
3395
|
-
expected: label,
|
|
3396
|
-
message,
|
|
3397
|
-
sourceSeq,
|
|
3398
|
-
});
|
|
3399
|
-
return;
|
|
3400
|
-
}
|
|
3401
|
-
if (Date.now() >= deadline) {
|
|
3402
|
-
const msg = `expected page URL${not} to match ${label}, got ${fmt(actual)}`;
|
|
3403
|
-
const sourceSeq = await probe.settle("toHaveURL", Date.now() - started, msg);
|
|
3404
|
-
recordAssertion({
|
|
3405
|
-
matcher: "toHaveURL",
|
|
3406
|
-
negated,
|
|
3407
|
-
passed: false,
|
|
3408
|
-
actual: safeSerialize(actual),
|
|
3409
|
-
expected: label,
|
|
3410
|
-
error: msg,
|
|
3411
|
-
message,
|
|
3412
|
-
sourceSeq,
|
|
3413
|
-
});
|
|
3414
|
-
throw new ExpectationError(msg);
|
|
3415
|
-
}
|
|
3416
|
-
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
3417
|
-
}
|
|
3418
|
-
},
|
|
3163
|
+
toHaveURL: (expected, opts) => recordWebAssertion(
|
|
3164
|
+
"toHaveURL", describeUrlPattern(expected), negated, message,
|
|
3165
|
+
() => probe.assertURL(negated, expected, opts),
|
|
3166
|
+
(duration, error) => probe.settle("toHaveURL", duration, error),
|
|
3167
|
+
),
|
|
3419
3168
|
};
|
|
3420
3169
|
}
|
|
3421
3170
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { expect as assert, ExpectationError } from "./index.js";
|
|
3
|
+
import { desktopStrategy, getLocatorProbe, makeLocator, type LocatorBackend } from "./locator.js";
|
|
4
|
+
import { recordBrowser, startRecording, stopRecording } from "./recorder.js";
|
|
5
|
+
|
|
6
|
+
afterEach(() => stopRecording());
|
|
7
|
+
|
|
8
|
+
// Test our recording/error boundary separately from native browser semantics.
|
|
9
|
+
function subject(evaluate: () => Promise<void>) {
|
|
10
|
+
const backend: LocatorBackend = {
|
|
11
|
+
async pageOp() { throw new Error("unexpected action"); },
|
|
12
|
+
async silentRead() { throw new Error("unexpected backend read"); },
|
|
13
|
+
async rawTap() {},
|
|
14
|
+
async recordSettled() { throw new Error("unexpected backend recording"); },
|
|
15
|
+
};
|
|
16
|
+
const locator = makeLocator(backend, desktopStrategy, { steps: [{ m: "locator", args: ["#target"] }] });
|
|
17
|
+
Object.assign(getLocatorProbe(locator), {
|
|
18
|
+
assert: evaluate,
|
|
19
|
+
nearMiss: async () => { throw new Error("diagnostics unavailable"); },
|
|
20
|
+
settle: async (action: string, durationMs: number, error?: string) =>
|
|
21
|
+
recordBrowser({ action, selector: 'css "#target"', durationMs, error }),
|
|
22
|
+
});
|
|
23
|
+
return locator;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const cause of [undefined, new Error("Connection closed")]) {
|
|
27
|
+
for (const negated of [false, true]) {
|
|
28
|
+
test(`records a failed ${negated ? "negative" : "positive"} assertion even for ${String(cause)}`, async () => {
|
|
29
|
+
const locator = subject(async () => { throw cause; });
|
|
30
|
+
startRecording();
|
|
31
|
+
const matcher = negated ? assert(locator).not : assert(locator);
|
|
32
|
+
await expect(matcher.toHaveText("Saved")).rejects.toBeInstanceOf(ExpectationError);
|
|
33
|
+
const events = stopRecording();
|
|
34
|
+
expect(events).toHaveLength(2);
|
|
35
|
+
expect(events[0]).toMatchObject({ kind: "browser", error: expect.any(String) });
|
|
36
|
+
expect(events[1]).toMatchObject({ kind: "assertion", passed: false, negated, sourceSeq: events[0]!.seq });
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("records native failure evidence even if supplemental diagnostics fail", async () => {
|
|
42
|
+
const cause = Object.assign(new Error("\u001b[31melement(s) not found\u001b[0m"), { matcherResult: { actual: [] } });
|
|
43
|
+
startRecording();
|
|
44
|
+
await expect(assert(subject(async () => { throw cause; }), "saved state").toHaveText(["Saved"]))
|
|
45
|
+
.rejects.toMatchObject({ message: "element(s) not found", cause });
|
|
46
|
+
expect(stopRecording()[1]).toMatchObject({
|
|
47
|
+
kind: "assertion", passed: false, actual: [], expected: ["Saved"], message: "saved state",
|
|
48
|
+
error: "element(s) not found",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("success records provenance without fabricating an observed value", async () => {
|
|
53
|
+
startRecording();
|
|
54
|
+
await assert(subject(async () => {})).toHaveText(/Saved/);
|
|
55
|
+
const events = stopRecording();
|
|
56
|
+
expect(events).toHaveLength(2);
|
|
57
|
+
expect(events[1]).toMatchObject({ kind: "assertion", passed: true, expected: "/Saved/", sourceSeq: events[0]!.seq });
|
|
58
|
+
expect(events[1]!.actual).toBeUndefined();
|
|
59
|
+
});
|
|
@@ -101,8 +101,8 @@ describe("locatorFailureMessage", () => {
|
|
|
101
101
|
" 2) <button>Save all</button> aka getByRole('button', { name: 'Save all' })\n",
|
|
102
102
|
);
|
|
103
103
|
expect(locatorFailureMessage("role button", strict)).toBe(
|
|
104
|
-
"role button matches 2 elements, so it is ambiguous —
|
|
105
|
-
".nth(i)
|
|
104
|
+
"role button matches 2 elements, so it is ambiguous — scope to the intended container, " +
|
|
105
|
+
"use an exact role/name, or a stable test ID; use .first()/.nth(i) only when position is part of the test's contract:\n" +
|
|
106
106
|
" - <button>Save</button> aka getByRole('button', { name: 'Save' })\n" +
|
|
107
107
|
" - <button>Save all</button> aka getByRole('button', { name: 'Save all' })",
|
|
108
108
|
);
|
|
@@ -111,8 +111,8 @@ describe("locatorFailureMessage", () => {
|
|
|
111
111
|
test("an ambiguous locator with no listed matches still says what to do", () => {
|
|
112
112
|
const strict = new Error("strict mode violation: locator('p') resolved to 7 elements");
|
|
113
113
|
expect(locatorFailureMessage('css "p"', strict)).toBe(
|
|
114
|
-
'css "p" matches 7 elements, so it is ambiguous —
|
|
115
|
-
".nth(i)
|
|
114
|
+
'css "p" matches 7 elements, so it is ambiguous — scope to the intended container, ' +
|
|
115
|
+
"use an exact role/name, or a stable test ID; use .first()/.nth(i) only when position is part of the test's contract",
|
|
116
116
|
);
|
|
117
117
|
});
|
|
118
118
|
});
|
package/src/locator-errors.ts
CHANGED
|
@@ -124,7 +124,8 @@ function parseStrictViolation(err: unknown, label: string): LocatorFailure | und
|
|
|
124
124
|
.map((l) => ` - ${l.trim().replace(/^\d+\)\s*/, "")}`);
|
|
125
125
|
const head =
|
|
126
126
|
`${label} matches ${m[1]} elements, so it is ambiguous` +
|
|
127
|
-
" —
|
|
127
|
+
" — scope to the intended container, use an exact role/name, or a stable test ID; " +
|
|
128
|
+
"use .first()/.nth(i) only when position is part of the test's contract";
|
|
128
129
|
return {
|
|
129
130
|
kind: "strict",
|
|
130
131
|
message: matches.length ? `${head}:\n${matches.join("\n")}` : head,
|