@specific.dev/spectest 0.66.0 → 0.67.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/browser.js +42 -1
- package/dist/components/supabase.d.ts +0 -14
- package/dist/components/supabase.js +2 -8
- package/dist/daemon.js +83 -31
- package/dist/harness/intercept.d.ts +22 -0
- package/dist/harness/intercept.js +29 -0
- package/dist/index.d.ts +52 -16
- package/dist/index.js +76 -17
- package/dist/locator-errors.d.ts +19 -10
- package/dist/locator-errors.js +80 -20
- package/dist/locator-hints.d.ts +96 -0
- package/dist/locator-hints.js +403 -0
- package/dist/locator.d.ts +22 -0
- package/dist/locator.js +63 -9
- package/dist/page-snapshot.d.ts +42 -0
- package/dist/page-snapshot.js +149 -0
- package/dist/recorder.d.ts +16 -0
- package/dist/text-match.d.ts +39 -0
- package/dist/text-match.js +239 -0
- package/package.json +1 -1
- package/src/browser.ts +43 -1
- package/src/components/supabase.ts +2 -20
- package/src/daemon.ts +85 -29
- package/src/harness/intercept.test.ts +36 -0
- package/src/harness/intercept.ts +40 -0
- package/src/index.ts +159 -32
- package/src/locator-errors.test.ts +99 -11
- package/src/locator-errors.ts +98 -19
- package/src/locator-hints.test.ts +188 -0
- package/src/locator-hints.ts +514 -0
- package/src/locator.ts +72 -9
- package/src/page-snapshot.test.ts +100 -0
- package/src/page-snapshot.ts +180 -0
- package/src/recorder.ts +16 -0
- package/src/text-match.test.ts +132 -0
- package/src/text-match.ts +285 -0
package/dist/browser.js
CHANGED
|
@@ -34,6 +34,7 @@ import { fileURLToPath } from "node:url";
|
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
35
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
36
36
|
import { wrap } from "./inspect.js";
|
|
37
|
+
import { capturePageStructure } from "./page-snapshot.js";
|
|
37
38
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
38
39
|
import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
|
|
39
40
|
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
@@ -1122,6 +1123,37 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1122
1123
|
// when rrweb's load-deferred full snapshot is most likely to be
|
|
1123
1124
|
// missing from the chunk we're about to drain (see `drainExpr`).
|
|
1124
1125
|
let lastDrainUrl = null;
|
|
1126
|
+
// Failed steps that captured their page. Bounded per session per case: a
|
|
1127
|
+
// test that catches its own locator errors in a loop must not pay for a
|
|
1128
|
+
// capture on each one, and three pages is already more than a reader will
|
|
1129
|
+
// open. `ctx.poll` needs no bookkeeping here — it truncates the events of
|
|
1130
|
+
// every superseded attempt, and each capture rides its own event.
|
|
1131
|
+
let pageCaptures = 0;
|
|
1132
|
+
const PAGE_CAPTURE_LIMIT = 3;
|
|
1133
|
+
/** The page behind a failed step, for its event (see page-snapshot.ts).
|
|
1134
|
+
* Best-effort and quiet: this runs while an error is already on its way
|
|
1135
|
+
* up, and must never become the failure the author sees. */
|
|
1136
|
+
async function failedPageStructure(action, error) {
|
|
1137
|
+
// No recorder means nothing is recording this op (an eval, a library
|
|
1138
|
+
// caller), so there is no event for the capture to ride out on.
|
|
1139
|
+
if (!recorder || recordingEnded || viewClosed)
|
|
1140
|
+
return undefined;
|
|
1141
|
+
if (pageCaptures >= PAGE_CAPTURE_LIMIT)
|
|
1142
|
+
return undefined;
|
|
1143
|
+
pageCaptures += 1;
|
|
1144
|
+
try {
|
|
1145
|
+
return await capturePageStructure(holder.page, {
|
|
1146
|
+
session: recorder.sessionName ?? "",
|
|
1147
|
+
// A desktop view has no device preset; a phone-emulated one does.
|
|
1148
|
+
kind: holder.device ? "mobile" : "browser",
|
|
1149
|
+
action,
|
|
1150
|
+
error,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
return undefined;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1125
1157
|
/** Session provenance stamped on every recorded op: which replay player
|
|
1126
1158
|
* the step belongs to, which named browser it acted on, and where in
|
|
1127
1159
|
* the player to seek (`endT`, in rrweb's clock). */
|
|
@@ -1203,12 +1235,17 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1203
1235
|
catch (err) {
|
|
1204
1236
|
const e = err;
|
|
1205
1237
|
const endT = Date.now();
|
|
1238
|
+
const error = e?.message ?? String(err);
|
|
1239
|
+
// Captured after the clock stops, so the diagnostic is not billed to
|
|
1240
|
+
// the step, and before the event is recorded, so it rides it.
|
|
1241
|
+
const pageStructure = await failedPageStructure(action, error);
|
|
1206
1242
|
recordBrowser({
|
|
1207
1243
|
action,
|
|
1208
1244
|
...fields,
|
|
1209
1245
|
...sessionFields(endT),
|
|
1210
1246
|
durationMs: endT - t,
|
|
1211
|
-
error
|
|
1247
|
+
error,
|
|
1248
|
+
...(pageStructure ? { pageStructure } : {}),
|
|
1212
1249
|
}, resv);
|
|
1213
1250
|
// Still try to drain — the failure itself may have produced
|
|
1214
1251
|
// useful rrweb events (mutations from a half-loaded page, etc.).
|
|
@@ -1536,12 +1573,16 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1536
1573
|
// where the matcher started polling, since that's what `tOffsetMs` means
|
|
1537
1574
|
// everywhere else (the ops that reserve up front stamp their start).
|
|
1538
1575
|
const endT = Date.now();
|
|
1576
|
+
// A matcher that ran out of budget is the other half of a failed
|
|
1577
|
+
// browser step, and the page it settled on is the same evidence.
|
|
1578
|
+
const pageStructure = error ? await failedPageStructure(action, error) : undefined;
|
|
1539
1579
|
const seq = recordBrowser({
|
|
1540
1580
|
action,
|
|
1541
1581
|
...fields,
|
|
1542
1582
|
...sessionFields(endT),
|
|
1543
1583
|
durationMs: waitedMs,
|
|
1544
1584
|
...(error ? { error } : {}),
|
|
1585
|
+
...(pageStructure ? { pageStructure } : {}),
|
|
1545
1586
|
}, reserveBackdated(waitedMs));
|
|
1546
1587
|
// Drain the rrweb the page buffered while the matcher waited into this
|
|
1547
1588
|
// step's chunk, so `settledTarget` has bounds to seek into.
|
|
@@ -33,20 +33,6 @@ export interface SupabaseOptions {
|
|
|
33
33
|
storage?: boolean;
|
|
34
34
|
/** Include Realtime (`<name>-realtime`). Default `true`. */
|
|
35
35
|
realtime?: boolean;
|
|
36
|
-
/**
|
|
37
|
-
* Edge functions. `isolation` picks how they run on the edge runtime:
|
|
38
|
-
* `"shared"` (default) loads every function into **one** isolate, so a
|
|
39
|
-
* shared module graph (`_shared/`, npm packages) is loaded once rather than
|
|
40
|
-
* once per function — measured on a 51-function project as 230 MB against
|
|
41
|
-
* 3.6 GB. Hosted Supabase runs one isolate per function, and the one thing
|
|
42
|
-
* that differs is module-level state: a singleton in `_shared` is one
|
|
43
|
-
* object for all functions here, one per function there. `"per-function"`
|
|
44
|
-
* runs it the hosted way. Either way the runtime, the request each
|
|
45
|
-
* function sees and the per-function `verify_jwt` are the same.
|
|
46
|
-
*/
|
|
47
|
-
functions?: {
|
|
48
|
-
isolation?: "shared" | "per-function";
|
|
49
|
-
};
|
|
50
36
|
/**
|
|
51
37
|
* Also serve the gateway over **HTTPS** at `https://<hostname>` via the
|
|
52
38
|
* daemon's CA-trusted TLS reverse proxy (in addition to the plain
|
|
@@ -698,9 +698,6 @@ async function validJwt(token: string): Promise<boolean> {
|
|
|
698
698
|
}
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
-
/** How the functions run: every function in one isolate (the default), or
|
|
702
|
-
* one isolate per function as hosted Supabase does — see sharedWorker. */
|
|
703
|
-
const ISOLATION = Deno.env.get("FUNCTIONS_ISOLATION") === "per-function" ? "per-function" : "shared";
|
|
704
701
|
/** Where the generated entry of the shared isolate lives — outside the repo
|
|
705
702
|
* mount, which is read-only and the user's checkout, and NOT under /tmp:
|
|
706
703
|
* the runtime gives its workers an in-memory /tmp, so a file the main
|
|
@@ -780,7 +777,8 @@ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
|
|
|
780
777
|
// what differs from hosted Supabase is that module-level state is one
|
|
781
778
|
// object for every function rather than one per function.
|
|
782
779
|
//
|
|
783
|
-
//
|
|
780
|
+
// There is deliberately no opt-out: it stands in wherever it can be exact,
|
|
781
|
+
// and where it cannot the per-function isolates are used. A function with its own
|
|
784
782
|
// deno.json / import map (per-function resolution the one config cannot
|
|
785
783
|
// express), a functions/deno.jsonc (comments; not merged) or a config that
|
|
786
784
|
// names an importMap keep the per-function isolates, and so does a shared
|
|
@@ -929,7 +927,6 @@ async function writeShared(names: string[]): Promise<string | null> {
|
|
|
929
927
|
/** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
|
|
930
928
|
* the functions run one isolate each — decided once, with the reason logged. */
|
|
931
929
|
async function sharedWorker(): Promise<any | null> {
|
|
932
|
-
if (ISOLATION !== "shared") return null;
|
|
933
930
|
if (!sharedSpec) {
|
|
934
931
|
sharedSpec = (async () => {
|
|
935
932
|
const names = await functionNames();
|
|
@@ -1729,9 +1726,6 @@ export function supabase(opts = {}) {
|
|
|
1729
1726
|
JWT_SECRET: jwtSecret,
|
|
1730
1727
|
// Where the router looks for `<name>/`, inside the repo mount.
|
|
1731
1728
|
FUNCTIONS_DIR: fnServePath,
|
|
1732
|
-
// One isolate for all functions (default) or one each — see the
|
|
1733
|
-
// `functions` option and the router's shared-isolate notes.
|
|
1734
|
-
FUNCTIONS_ISOLATION: opts.functions?.isolation ?? "shared",
|
|
1735
1729
|
// The default a function inherits when the config says nothing:
|
|
1736
1730
|
// reject an unauthenticated call, as hosted Supabase does.
|
|
1737
1731
|
VERIFY_JWT: "true",
|
package/dist/daemon.js
CHANGED
|
@@ -42,7 +42,7 @@ import { pollUntilReady } from "./harness/ready-poll.js";
|
|
|
42
42
|
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
43
43
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
|
|
44
44
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
45
|
-
import { InterceptRegistry, runChain, } from "./harness/intercept.js";
|
|
45
|
+
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
46
46
|
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
47
47
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
48
48
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
@@ -2301,35 +2301,61 @@ function ingressClaimsHostname(hostname) {
|
|
|
2301
2301
|
}
|
|
2302
2302
|
return false;
|
|
2303
2303
|
}
|
|
2304
|
-
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2304
|
+
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2305
|
+
*
|
|
2306
|
+
* A forced 503 is the step doing exactly what its description says, so it is
|
|
2307
|
+
* **not** marked failed — reddening the outcome the test asked for trains the
|
|
2308
|
+
* reader to ignore the colour. Only a handler that threw is marked, because
|
|
2309
|
+
* that 500 is a bug in the middleware rather than the outage it stands for. */
|
|
2305
2310
|
function recordInterceptedRequest(it, rec, durationMs) {
|
|
2306
2311
|
const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
|
|
2307
2312
|
if (parentSeq === undefined || !isRecording())
|
|
2308
2313
|
return;
|
|
2309
|
-
const by = rec.
|
|
2310
|
-
? "
|
|
2311
|
-
: rec.answeredBy === "
|
|
2312
|
-
? "
|
|
2313
|
-
:
|
|
2314
|
+
const by = rec.threw
|
|
2315
|
+
? "the interceptor threw — 500 from spectest, not from your handler"
|
|
2316
|
+
: rec.answeredBy === "handler"
|
|
2317
|
+
? "answered by the interceptor"
|
|
2318
|
+
: rec.answeredBy === "modified"
|
|
2319
|
+
? "upstream answer replaced by the interceptor"
|
|
2320
|
+
: "passed through to the upstream";
|
|
2314
2321
|
recordStep({
|
|
2315
2322
|
kind: "intercept-request",
|
|
2316
2323
|
parentSeq,
|
|
2317
2324
|
title: `${rec.method} ${rec.path} → ${rec.status}`,
|
|
2318
|
-
status: rec.
|
|
2325
|
+
status: rec.threw ? "failed" : "passed",
|
|
2326
|
+
// Method, path and status are already the title; the one thing the row
|
|
2327
|
+
// cannot say for itself is who produced that status.
|
|
2319
2328
|
blocks: [
|
|
2320
|
-
{
|
|
2321
|
-
type: "kv",
|
|
2322
|
-
rows: [
|
|
2323
|
-
{ label: "Host", value: it.hostname },
|
|
2324
|
-
{ label: "Request", value: `${rec.method} ${rec.path}` },
|
|
2325
|
-
{ label: "Status", value: String(rec.status) },
|
|
2326
|
-
{ label: "Answered", value: by },
|
|
2327
|
-
],
|
|
2328
|
-
},
|
|
2329
|
+
{ type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
|
|
2329
2330
|
],
|
|
2330
2331
|
durationMs,
|
|
2331
2332
|
});
|
|
2332
2333
|
}
|
|
2334
|
+
/** The handler's own source, which is what the interceptor actually does.
|
|
2335
|
+
*
|
|
2336
|
+
* Free and impossible to let rot: Bun runs the project's TypeScript
|
|
2337
|
+
* directly, so `toString()` is the text the author wrote. It is the step's
|
|
2338
|
+
* body, under the description — a mechanism the reader can check against
|
|
2339
|
+
* the intent the description claims. A handler long enough to be a wall of
|
|
2340
|
+
* code folds into a disclosure instead of pushing the requests off screen.
|
|
2341
|
+
*/
|
|
2342
|
+
function handlerBlocks(handler) {
|
|
2343
|
+
let src;
|
|
2344
|
+
try {
|
|
2345
|
+
src = String(handler);
|
|
2346
|
+
}
|
|
2347
|
+
catch {
|
|
2348
|
+
return [];
|
|
2349
|
+
}
|
|
2350
|
+
const code = { type: "code", lang: "ts", label: "Handler", code: src };
|
|
2351
|
+
return src.split("\n").length > HANDLER_FOLD_LINES
|
|
2352
|
+
? [{ type: "details", summary: "Handler", blocks: [code] }]
|
|
2353
|
+
: [code];
|
|
2354
|
+
}
|
|
2355
|
+
/** Past this many lines the handler source is folded away. Chosen so the
|
|
2356
|
+
* shapes this feature is for — a one-line forced status, a short
|
|
2357
|
+
* fail-twice-then-pass — always show, and only a real program hides. */
|
|
2358
|
+
const HANDLER_FOLD_LINES = 15;
|
|
2333
2359
|
/**
|
|
2334
2360
|
* Put middleware in front of a hostname the ingress serves — the
|
|
2335
2361
|
* implementation behind `ctx.intercept`.
|
|
@@ -2338,15 +2364,19 @@ function recordInterceptedRequest(it, rec, durationMs) {
|
|
|
2338
2364
|
* the daemon (DNS does not point here), so the interceptor could only be
|
|
2339
2365
|
* silent — and silence is the failure mode this whole layer is designed
|
|
2340
2366
|
* against. The message names the two ways to get a route.
|
|
2367
|
+
*
|
|
2368
|
+
* The `description` is required because it is the step's whole title in the
|
|
2369
|
+
* timeline and in the CLI's failure detail: it is what tells a reader why
|
|
2370
|
+
* the UI under test went to its error state. The handler source says how;
|
|
2371
|
+
* only the author can say what it means.
|
|
2341
2372
|
*/
|
|
2342
|
-
function registerInterceptor(
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
if (!handler) {
|
|
2373
|
+
function registerInterceptor(target, description, handler) {
|
|
2374
|
+
const { hostname: host, path } = parseTarget(target);
|
|
2375
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
2376
|
+
throw new Error(`ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
|
|
2377
|
+
`environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`);
|
|
2378
|
+
}
|
|
2379
|
+
if (typeof handler !== "function") {
|
|
2350
2380
|
throw new Error("ctx.intercept: a handler (req, next) => Response is required");
|
|
2351
2381
|
}
|
|
2352
2382
|
if (!ingressClaimsHostname(host)) {
|
|
@@ -2357,19 +2387,41 @@ function registerInterceptor(hostname, pathOrHandler, maybeHandler) {
|
|
|
2357
2387
|
}
|
|
2358
2388
|
const resv = reserveEvent();
|
|
2359
2389
|
const it = INTERCEPTORS.register(host, path, handler);
|
|
2390
|
+
const where = `${host}${it.path === "/" ? "" : it.path}`;
|
|
2360
2391
|
const seq = recordStep({
|
|
2361
2392
|
kind: "intercept",
|
|
2362
|
-
|
|
2393
|
+
// The description alone. The pill already says INTERCEPT, and the
|
|
2394
|
+
// target is in the panel — a row that repeats both spends its width
|
|
2395
|
+
// on what the reader can already see and none of it on the one thing
|
|
2396
|
+
// only the author knows.
|
|
2397
|
+
title: description.trim(),
|
|
2363
2398
|
blocks: [
|
|
2399
|
+
...handlerBlocks(handler),
|
|
2364
2400
|
{
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2401
|
+
// The target IS the summary line, so the closed disclosure still
|
|
2402
|
+
// says where the interceptor sits — a generic "Target" label
|
|
2403
|
+
// would spend the one visible line saying nothing.
|
|
2404
|
+
type: "details",
|
|
2405
|
+
summary: where,
|
|
2406
|
+
blocks: [
|
|
2407
|
+
{
|
|
2408
|
+
type: "kv",
|
|
2409
|
+
rows: [
|
|
2410
|
+
{
|
|
2411
|
+
label: "Matches",
|
|
2412
|
+
value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
|
|
2413
|
+
},
|
|
2414
|
+
{
|
|
2415
|
+
label: "Until",
|
|
2416
|
+
value: it.scope === undefined ? "remove()" : "the end of this test",
|
|
2417
|
+
},
|
|
2418
|
+
],
|
|
2419
|
+
},
|
|
2369
2420
|
],
|
|
2370
2421
|
},
|
|
2371
2422
|
],
|
|
2372
|
-
|
|
2423
|
+
// No duration: this is a marker for the moment the interceptor went
|
|
2424
|
+
// up, and "(0 ms)" reads as a step that did nothing.
|
|
2373
2425
|
}, resv);
|
|
2374
2426
|
if (seq !== undefined)
|
|
2375
2427
|
INTERCEPT_STEP_SEQ.set(it.id, seq);
|
|
@@ -48,6 +48,12 @@ export interface InterceptedRequest {
|
|
|
48
48
|
* upstream via `next()` untouched (`upstream`), or the upstream's answer
|
|
49
49
|
* replaced by the interceptor after `next()` (`modified`). */
|
|
50
50
|
answeredBy: "handler" | "upstream" | "modified";
|
|
51
|
+
/** The handler threw (or returned something that is not a `Response`), so
|
|
52
|
+
* the 500 below is a bug in the test's own middleware rather than the
|
|
53
|
+
* outage it was asked to produce. The distinction cannot be recovered
|
|
54
|
+
* from the status: a deliberate 500 and a crashed handler are the same
|
|
55
|
+
* number, and only this tells the timeline which one to mark as wrong. */
|
|
56
|
+
threw?: boolean;
|
|
51
57
|
}
|
|
52
58
|
export interface Interceptor {
|
|
53
59
|
id: number;
|
|
@@ -61,6 +67,22 @@ export interface Interceptor {
|
|
|
61
67
|
calls: number;
|
|
62
68
|
requests: InterceptedRequest[];
|
|
63
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Split an intercept target — `app.test`, `app.test/api/sync`,
|
|
72
|
+
* `*.example.com/v1` — into the hostname and the mount path.
|
|
73
|
+
*
|
|
74
|
+
* The path rides the target rather than a second argument because that is
|
|
75
|
+
* what it is: part of what the interceptor claims, not a separate knob. It
|
|
76
|
+
* also keeps the signature down to one string, so the description that
|
|
77
|
+
* follows can never be mistaken for a path.
|
|
78
|
+
*
|
|
79
|
+
* A scheme is tolerated (`https://app.test/api`) since that is how the same
|
|
80
|
+
* address is written everywhere else.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseTarget(target: string): {
|
|
83
|
+
hostname: string;
|
|
84
|
+
path: string;
|
|
85
|
+
};
|
|
64
86
|
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
65
87
|
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
66
88
|
export declare function pathMounts(mount: string, pathname: string): boolean;
|
|
@@ -33,6 +33,34 @@
|
|
|
33
33
|
* plain `Request`/`Response` objects.
|
|
34
34
|
*/
|
|
35
35
|
import { isWildcard, wildcardSuffix } from "./hostmatch";
|
|
36
|
+
/**
|
|
37
|
+
* Split an intercept target — `app.test`, `app.test/api/sync`,
|
|
38
|
+
* `*.example.com/v1` — into the hostname and the mount path.
|
|
39
|
+
*
|
|
40
|
+
* The path rides the target rather than a second argument because that is
|
|
41
|
+
* what it is: part of what the interceptor claims, not a separate knob. It
|
|
42
|
+
* also keeps the signature down to one string, so the description that
|
|
43
|
+
* follows can never be mistaken for a path.
|
|
44
|
+
*
|
|
45
|
+
* A scheme is tolerated (`https://app.test/api`) since that is how the same
|
|
46
|
+
* address is written everywhere else.
|
|
47
|
+
*/
|
|
48
|
+
export function parseTarget(target) {
|
|
49
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
50
|
+
throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
|
|
51
|
+
}
|
|
52
|
+
const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
53
|
+
const slash = bare.indexOf("/");
|
|
54
|
+
const hostname = slash === -1 ? bare : bare.slice(0, slash);
|
|
55
|
+
const rest = slash === -1 ? undefined : bare.slice(slash);
|
|
56
|
+
if (hostname === "") {
|
|
57
|
+
throw new Error(`intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`);
|
|
58
|
+
}
|
|
59
|
+
if (hostname.includes("?") || hostname.includes("#")) {
|
|
60
|
+
throw new Error(`intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`);
|
|
61
|
+
}
|
|
62
|
+
return { hostname, path: normalizeMount(rest) };
|
|
63
|
+
}
|
|
36
64
|
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
37
65
|
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
38
66
|
export function pathMounts(mount, pathname) {
|
|
@@ -159,6 +187,7 @@ export async function runChain(chain, req, upstream, observe) {
|
|
|
159
187
|
res = new Response(`spectest-daemon: interceptor for ${it.hostname} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
|
|
160
188
|
record.answeredBy = "handler";
|
|
161
189
|
record.status = 500;
|
|
190
|
+
record.threw = true;
|
|
162
191
|
it.calls++;
|
|
163
192
|
it.requests.push(record);
|
|
164
193
|
observe?.(it, record, Date.now() - started);
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, type Mcp, type McpOption
|
|
|
14
14
|
import type { Mcp, McpOptions } from "./mcp.js";
|
|
15
15
|
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
|
|
16
16
|
import type { Locator } from "./locator.js";
|
|
17
|
+
import { type TextMatchOptions } from "./text-match.js";
|
|
17
18
|
export type { UrlPattern } from "./url-match.js";
|
|
18
19
|
import type { UrlPattern } from "./url-match.js";
|
|
19
20
|
export type { Mobile, MobileApp } from "./mobile.js";
|
|
@@ -34,7 +35,8 @@ export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
|
34
35
|
*/
|
|
35
36
|
export interface Interception {
|
|
36
37
|
hostname: string;
|
|
37
|
-
/** The normalised mount path (`"/"` when
|
|
38
|
+
/** The normalised mount path parsed off the target (`"/"` when the
|
|
39
|
+
* target was a bare hostname). */
|
|
38
40
|
path: string;
|
|
39
41
|
/** How many requests the interceptor has seen. Provenance-wrapped, so an
|
|
40
42
|
* `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
|
|
@@ -381,12 +383,23 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
381
383
|
* make your **own** backend misbehave for one test: force a 500 from an
|
|
382
384
|
* API route, add latency, fail twice then pass, or just count calls.
|
|
383
385
|
*
|
|
386
|
+
* `target` is the hostname, optionally with a mount path:
|
|
387
|
+
* `"app.test"` claims every request to that host, `"app.test/api/sync"`
|
|
388
|
+
* claims that path and everything below it (like `app.use(path, fn)`).
|
|
389
|
+
*
|
|
390
|
+
* `description` is what the environment now **does**, in the present
|
|
391
|
+
* tense — it is the step's whole title in the timeline and in the CLI's
|
|
392
|
+
* failure detail, so it is what tells a reader why the page under test
|
|
393
|
+
* went to its error state. Write the effect, not the act of intercepting
|
|
394
|
+
* (the step is already labelled INTERCEPT) and not the test's goal:
|
|
395
|
+
* `"the sync endpoint returns 503"`, not `"intercept sync"`, `"mock the
|
|
396
|
+
* API"` or `"test error handling"`. The handler's own source is shown
|
|
397
|
+
* under it, so the description carries the intent, never the mechanism.
|
|
398
|
+
*
|
|
384
399
|
* Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
|
|
385
400
|
* response (a proxied service, or a fake). Return a `Response` to answer
|
|
386
401
|
* yourself, `next()` to pass through, or change what `next()` returned.
|
|
387
|
-
*
|
|
388
|
-
* (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
|
|
389
|
-
* registration order.
|
|
402
|
+
* Interceptors run in registration order.
|
|
390
403
|
*
|
|
391
404
|
* Only traffic that reaches the daemon can be intercepted: the browser,
|
|
392
405
|
* `ctx.fetch`, and any container that calls the hostname — so the service
|
|
@@ -400,16 +413,18 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
400
413
|
* `remove()`. Every request it sees is recorded under the intercept step.
|
|
401
414
|
*
|
|
402
415
|
* ```ts
|
|
403
|
-
* const outage = ctx.intercept(
|
|
404
|
-
*
|
|
416
|
+
* const outage = ctx.intercept(
|
|
417
|
+
* "api.test/functions/v1/sync",
|
|
418
|
+
* "the sync function returns 500",
|
|
419
|
+
* () => new Response("boom", { status: 500 }),
|
|
420
|
+
* );
|
|
405
421
|
* await page.getByRole("button", { name: "Sync" }).click();
|
|
406
422
|
* await expect(page.getByText("Retry")).toBeVisible();
|
|
407
423
|
* expect(outage.calls).toBe(1);
|
|
408
424
|
* outage.remove(); // the retry now reaches the real function
|
|
409
425
|
* ```
|
|
410
426
|
*/
|
|
411
|
-
intercept(
|
|
412
|
-
intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
|
|
427
|
+
intercept(target: string, description: string, handler: InterceptHandler): Interception;
|
|
413
428
|
/**
|
|
414
429
|
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
415
430
|
*
|
|
@@ -1672,6 +1687,14 @@ interface Matchers {
|
|
|
1672
1687
|
export interface Expectation extends Matchers {
|
|
1673
1688
|
not: Matchers;
|
|
1674
1689
|
}
|
|
1690
|
+
/** Options for the text matchers — playwright's set, same names, same
|
|
1691
|
+
* meanings. */
|
|
1692
|
+
export interface TextMatcherOptions extends TextMatchOptions {
|
|
1693
|
+
timeout?: number;
|
|
1694
|
+
/** Read `innerText` (what the page renders — hidden elements dropped,
|
|
1695
|
+
* `text-transform` applied) instead of `textContent`. */
|
|
1696
|
+
useInnerText?: boolean;
|
|
1697
|
+
}
|
|
1675
1698
|
/**
|
|
1676
1699
|
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
1677
1700
|
* `expect(locator)` matchers. Each polls the element until it passes or a
|
|
@@ -1687,14 +1710,27 @@ export interface LocatorMatchers {
|
|
|
1687
1710
|
toBeHidden(opts?: {
|
|
1688
1711
|
timeout?: number;
|
|
1689
1712
|
}): Promise<void>;
|
|
1690
|
-
/**
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1713
|
+
/**
|
|
1714
|
+
* The element's text equals `expected`, or matches it when it is a RegExp.
|
|
1715
|
+
*
|
|
1716
|
+
* **Whitespace is normalized on both sides**, exactly as playwright does
|
|
1717
|
+
* it: the text is trimmed and every run of whitespace becomes one space.
|
|
1718
|
+
* So a typed space matches the no-break space (U+00A0) that
|
|
1719
|
+
* `Intl.NumberFormat` puts between thousands, and a value wrapped across
|
|
1720
|
+
* two lines in the markup matches the one-line string you wrote.
|
|
1721
|
+
*
|
|
1722
|
+
* Pass an **array** to assert over every element the locator matches, in
|
|
1723
|
+
* order; the counts must then agree.
|
|
1724
|
+
*/
|
|
1725
|
+
toHaveText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1726
|
+
/**
|
|
1727
|
+
* The element's text contains `expected` — a substring, or a RegExp tested
|
|
1728
|
+
* against the text. Whitespace is normalized as in {@link toHaveText}.
|
|
1729
|
+
*
|
|
1730
|
+
* An **array** asserts that the matched elements contain these texts in
|
|
1731
|
+
* order; unlike `toHaveText` extra elements between them are allowed.
|
|
1732
|
+
*/
|
|
1733
|
+
toContainText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1698
1734
|
/** The input's value equals `expected` (or matches a RegExp). */
|
|
1699
1735
|
toHaveValue(expected: string | RegExp, opts?: {
|
|
1700
1736
|
timeout?: number;
|