@specific.dev/spectest 0.54.1 → 0.56.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/daemon.js +10 -0
- package/dist/harness/raw-fetch.d.ts +7 -0
- package/dist/harness/raw-fetch.js +33 -0
- package/dist/ids.d.ts +9 -0
- package/dist/ids.js +11 -1
- package/dist/index.d.ts +43 -0
- package/dist/index.js +52 -3
- package/dist/locator-errors.d.ts +21 -0
- package/dist/locator-errors.js +142 -0
- package/dist/locator.js +18 -6
- package/dist/mcp-auth.d.ts +176 -0
- package/dist/mcp-auth.js +455 -0
- package/dist/mcp-transport.d.ts +130 -0
- package/dist/mcp-transport.js +330 -0
- package/dist/mcp.d.ts +246 -0
- package/dist/mcp.js +1060 -0
- package/dist/recorder.d.ts +5 -0
- package/package.json +1 -1
- package/src/daemon.ts +10 -0
- package/src/harness/raw-fetch.ts +36 -0
- package/src/ids.ts +12 -1
- package/src/index.ts +114 -3
- package/src/locator-errors.test.ts +125 -0
- package/src/locator-errors.ts +153 -0
- package/src/locator.ts +25 -6
- package/src/mcp-auth.ts +626 -0
- package/src/mcp-transport.ts +427 -0
- package/src/mcp.test.ts +94 -0
- package/src/mcp.ts +1435 -0
- package/src/recorder.ts +5 -2
package/dist/daemon.js
CHANGED
|
@@ -45,6 +45,10 @@ import { runContainerArgs } from "./harness/container-run.js";
|
|
|
45
45
|
import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
|
|
46
46
|
import { conflict, notFound, requireString, } from "./harness/methods.js";
|
|
47
47
|
import { openTerminal } from "./terminal.js";
|
|
48
|
+
// `ctx.mcp(url)`. One client per call, no registry: an authenticated
|
|
49
|
+
// client is passed to descendants as a test's return value.
|
|
50
|
+
import { openMcp } from "./mcp.js";
|
|
51
|
+
import { setRawFetch } from "./harness/raw-fetch.js";
|
|
48
52
|
import { readAnnotation } from "./annotate.js";
|
|
49
53
|
import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
50
54
|
import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
|
|
@@ -3392,6 +3396,10 @@ function isTransportError(err) {
|
|
|
3392
3396
|
*/
|
|
3393
3397
|
function installFetchWrapper() {
|
|
3394
3398
|
const original = globalThis.fetch;
|
|
3399
|
+
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
3400
|
+
// wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
|
|
3401
|
+
// body to the end, which never finishes for an SSE stream.
|
|
3402
|
+
setRawFetch(original);
|
|
3395
3403
|
const wrappedFn = async (input, init) => {
|
|
3396
3404
|
const start = Date.now();
|
|
3397
3405
|
const resv = reserveEvent();
|
|
@@ -4042,6 +4050,7 @@ async function runOne(testCase) {
|
|
|
4042
4050
|
openTerminal: recordedOpenTerminal,
|
|
4043
4051
|
browser: trackedOpenBrowser,
|
|
4044
4052
|
mobile: trackedOpenMobile,
|
|
4053
|
+
mcp: openMcp,
|
|
4045
4054
|
testName: testCase.name,
|
|
4046
4055
|
parent,
|
|
4047
4056
|
};
|
|
@@ -4491,6 +4500,7 @@ async function evalCode(code, secrets) {
|
|
|
4491
4500
|
openTerminal: evalOpenTerminal,
|
|
4492
4501
|
browser: trackedOpenBrowser,
|
|
4493
4502
|
mobile: trackedOpenMobile,
|
|
4503
|
+
mcp: openMcp,
|
|
4494
4504
|
testName: "eval",
|
|
4495
4505
|
parent: undefined,
|
|
4496
4506
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Called by the daemon with the real `fetch`, before it installs its
|
|
2
|
+
* instrumented one. */
|
|
3
|
+
export declare function setRawFetch(fn: typeof fetch): void;
|
|
4
|
+
/** `fetch`, never the instrumented wrapper. Falls back to the global for
|
|
5
|
+
* a context where nothing was ever installed (unit tests, a plain
|
|
6
|
+
* script). */
|
|
7
|
+
export declare function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The real `fetch`, for SDK internals that must not go through the test
|
|
2
|
+
// recorder's wrapper.
|
|
3
|
+
//
|
|
4
|
+
// While a test (or an eval) runs, the daemon replaces `globalThis.fetch`
|
|
5
|
+
// with an instrumented one: it records an `http` event, and it hands back
|
|
6
|
+
// a WRAPPED response whose `ok` / `status` are provenance handles rather
|
|
7
|
+
// than a boolean and a number. That is exactly right for a test's own
|
|
8
|
+
// calls, and exactly wrong inside the SDK, in two ways:
|
|
9
|
+
//
|
|
10
|
+
// 1. `if (!res.ok)` is always false against a handle, because a handle
|
|
11
|
+
// is an object. An SDK client that checks it would read a `401` as a
|
|
12
|
+
// success. (`pauseRecording()` does not help: it stops the event, not
|
|
13
|
+
// the wrapper.)
|
|
14
|
+
// 2. The wrapper clones the response and reads the clone to the end so
|
|
15
|
+
// it can record the body. For a streaming reply — Server-Sent Events
|
|
16
|
+
// — that does not finish until the stream closes, so the caller is
|
|
17
|
+
// handed its response only after the stream it was waiting to read
|
|
18
|
+
// has already ended. A long-lived stream deadlocks.
|
|
19
|
+
//
|
|
20
|
+
// So SDK-internal HTTP goes through `rawFetch`. The daemon publishes the
|
|
21
|
+
// original here when it installs its wrapper.
|
|
22
|
+
let original;
|
|
23
|
+
/** Called by the daemon with the real `fetch`, before it installs its
|
|
24
|
+
* instrumented one. */
|
|
25
|
+
export function setRawFetch(fn) {
|
|
26
|
+
original = fn;
|
|
27
|
+
}
|
|
28
|
+
/** `fetch`, never the instrumented wrapper. Falls back to the global for
|
|
29
|
+
* a context where nothing was ever installed (unit tests, a plain
|
|
30
|
+
* script). */
|
|
31
|
+
export function rawFetch(input, init) {
|
|
32
|
+
return (original ?? globalThis.fetch)(input, init);
|
|
33
|
+
}
|
package/dist/ids.d.ts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
/** Mint a fresh id, e.g. `generateId("art")` → `art_0c3k9mpq…`. */
|
|
2
2
|
export declare function generateId(prefix: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool).
|
|
5
|
+
*
|
|
6
|
+
* Exported because every in-VM secret with a uniqueness requirement has
|
|
7
|
+
* the same problem as an id: Bun's pool is frozen into the snapshot, so
|
|
8
|
+
* sibling forks draw identical bytes. An OAuth PKCE verifier and `state`
|
|
9
|
+
* (`mcp-auth.ts`) must come from here, not from `crypto.getRandomValues`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function secureRandomBytes(n: number): Uint8Array;
|
package/dist/ids.js
CHANGED
|
@@ -38,7 +38,17 @@ export function generateId(prefix) {
|
|
|
38
38
|
.digest();
|
|
39
39
|
return `${prefix}_0${base32Crockford(digest.subarray(0, 16)).slice(0, RANDOM_LEN)}`;
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool).
|
|
43
|
+
*
|
|
44
|
+
* Exported because every in-VM secret with a uniqueness requirement has
|
|
45
|
+
* the same problem as an id: Bun's pool is frozen into the snapshot, so
|
|
46
|
+
* sibling forks draw identical bytes. An OAuth PKCE verifier and `state`
|
|
47
|
+
* (`mcp-auth.ts`) must come from here, not from `crypto.getRandomValues`.
|
|
48
|
+
*/
|
|
49
|
+
export function secureRandomBytes(n) {
|
|
50
|
+
return urandom(n);
|
|
51
|
+
}
|
|
42
52
|
function urandom(n) {
|
|
43
53
|
const buf = new Uint8Array(n);
|
|
44
54
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js
|
|
|
10
10
|
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
11
11
|
export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
|
|
12
12
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
13
|
+
export { McpHttpError, McpRpcError, McpAuthDeniedError, type Mcp, type McpOptions, type McpAuthorization, type McpChallenge, type McpIdentity, type McpToolResult, type McpToolInfo, type McpResourceInfo, type McpResourceContents, type McpPromptInfo, type McpPromptResult, type McpContent, type McpNotification, type McpSamplingRequest, type McpSamplingResult, type AuthorizeOptions, type AuthorizationServerInfo, } from "./mcp.js";
|
|
14
|
+
import type { Mcp, McpOptions } from "./mcp.js";
|
|
13
15
|
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
|
|
14
16
|
import type { Locator } from "./locator.js";
|
|
15
17
|
export type { UrlPattern } from "./url-match.js";
|
|
@@ -996,6 +998,47 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
|
|
|
996
998
|
* natural way to assert on the exit code.
|
|
997
999
|
*/
|
|
998
1000
|
openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
|
|
1001
|
+
/**
|
|
1002
|
+
* Connect to a Model Context Protocol server over HTTP, as an AI client
|
|
1003
|
+
* would. Every tool call, resource read and prompt renders as its own
|
|
1004
|
+
* step in the timeline, with the arguments and the result.
|
|
1005
|
+
*
|
|
1006
|
+
* ```ts
|
|
1007
|
+
* const mcp = await ctx.mcp("https://api.test/mcp");
|
|
1008
|
+
* const res = await mcp.call("create_invoice", { customer: "Acme" });
|
|
1009
|
+
* expect(res.json<{ id: string }>().id).toMatch(/^inv_/);
|
|
1010
|
+
* ```
|
|
1011
|
+
*
|
|
1012
|
+
* **Each call returns a NEW client.** There is no per-name registry like
|
|
1013
|
+
* `ctx.browser("alice")` has, and no default session. A client that has
|
|
1014
|
+
* authenticated is handed to the tests that need it, as this test's
|
|
1015
|
+
* return value — `ctx.parent` crosses the fork in daemon memory, live
|
|
1016
|
+
* connections included:
|
|
1017
|
+
*
|
|
1018
|
+
* ```ts
|
|
1019
|
+
* export const signedIn = env.test("sign in", async (ctx) => {
|
|
1020
|
+
* const mcp = await ctx.mcp(URL);
|
|
1021
|
+
* const auth = await mcp.authorize();
|
|
1022
|
+
* const page = await ctx.browser();
|
|
1023
|
+
* await page.goto(auth.url);
|
|
1024
|
+
* await page.getByRole("button", { name: "Allow" }).click();
|
|
1025
|
+
* await auth.complete();
|
|
1026
|
+
* return mcp;
|
|
1027
|
+
* });
|
|
1028
|
+
*
|
|
1029
|
+
* env.test("call a tool", { dependsOn: signedIn }, async (ctx) => {
|
|
1030
|
+
* await ctx.parent.call("create_invoice", { amount: 250 });
|
|
1031
|
+
* });
|
|
1032
|
+
* ```
|
|
1033
|
+
*
|
|
1034
|
+
* Two identities are two calls to `ctx.mcp` — nothing to name.
|
|
1035
|
+
*
|
|
1036
|
+
* A server that needs authorization answers `401`, which is reported and
|
|
1037
|
+
* not hidden: `ctx.mcp(url)` returns an unauthenticated client carrying
|
|
1038
|
+
* the challenge, and a call on it fails with the server's own rejection.
|
|
1039
|
+
* That is what makes "this tool is protected" a test.
|
|
1040
|
+
*/
|
|
1041
|
+
mcp(url: string, opts?: McpOptions): Promise<Mcp>;
|
|
999
1042
|
/**
|
|
1000
1043
|
* Open the headless browser. Backed by Chromium-over-CDP inside the VM.
|
|
1001
1044
|
*
|
package/dist/index.js
CHANGED
|
@@ -33,7 +33,12 @@ export { annotate } from "./annotate.js";
|
|
|
33
33
|
export { SQL } from "./sql.js";
|
|
34
34
|
export { RedisClient } from "./redis.js";
|
|
35
35
|
export { S3Client } from "./s3.js";
|
|
36
|
+
// The MCP client (`ctx.mcp`). Drives a Model Context Protocol server the
|
|
37
|
+
// way a real AI client would, with the OAuth flow a test runs through its
|
|
38
|
+
// own browser. See `mcp.ts`.
|
|
39
|
+
export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
|
|
36
40
|
import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
|
|
41
|
+
import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
|
|
37
42
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
38
43
|
// Low-level ingress primitives + the framework lowering that the friendly
|
|
39
44
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
@@ -549,7 +554,8 @@ function buildLocatorMatchers(loc, negated, message) {
|
|
|
549
554
|
// value for the timeline.
|
|
550
555
|
const run = async (matcher, timeout, check, describe, expected) => {
|
|
551
556
|
const started = Date.now();
|
|
552
|
-
const
|
|
557
|
+
const budget = timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
558
|
+
const deadline = started + budget;
|
|
553
559
|
let actual;
|
|
554
560
|
// Whether this matcher's subject is meant to be on screen, i.e. whether
|
|
555
561
|
// the step should carry a reveal target for the replay (see
|
|
@@ -560,20 +566,59 @@ function buildLocatorMatchers(loc, negated, message) {
|
|
|
560
566
|
const expectsGone = (visibility && (matcher === "toBeHidden") !== negated) ||
|
|
561
567
|
(matcher === "toHaveCount" && expected === 0);
|
|
562
568
|
const settleOpts = { reveal: !expectsGone };
|
|
569
|
+
// "That element is not there" said plainly, or `undefined` when the
|
|
570
|
+
// element IS there and the matcher's own description is the better
|
|
571
|
+
// sentence.
|
|
572
|
+
//
|
|
573
|
+
// Two ways to learn it. A matcher whose probe waits (textContent,
|
|
574
|
+
// inputValue, isEnabled …) has already been told, by the playwright
|
|
575
|
+
// timeout it caught. One whose probe answers instantly (isVisible) has
|
|
576
|
+
// not — `toBeVisible()` on a selector that matches nothing and one that
|
|
577
|
+
// matches a hidden element fail identically — so the count is read once,
|
|
578
|
+
// at failure time only. `toHaveCount` is left alone: "expected count 2,
|
|
579
|
+
// got 0" already says it, and better.
|
|
580
|
+
const elementFailure = async (err) => {
|
|
581
|
+
if (err !== undefined)
|
|
582
|
+
return locatorFailureMessage(probe.label, err);
|
|
583
|
+
if (expectsGone || matcher === "toHaveCount")
|
|
584
|
+
return undefined;
|
|
585
|
+
try {
|
|
586
|
+
if ((await probe.count()) === 0) {
|
|
587
|
+
return `No element matches ${probe.label} (waited ${formatWaited(budget)})`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* The page is gone or the chain is invalid — the matcher's own
|
|
592
|
+
description still says what was expected. */
|
|
593
|
+
}
|
|
594
|
+
return undefined;
|
|
595
|
+
};
|
|
596
|
+
// The last error a probe threw, kept for the failure message: a matcher
|
|
597
|
+
// whose read waits for the element (textContent, inputValue, isEnabled …)
|
|
598
|
+
// reports a missing element as a playwright timeout, and that — not
|
|
599
|
+
// "got <error: Timeout 5000ms exceeded …>" — is the sentence to fail with.
|
|
600
|
+
let probeError;
|
|
563
601
|
for (;;) {
|
|
564
602
|
let satisfied;
|
|
565
603
|
try {
|
|
566
604
|
const r = await check();
|
|
567
605
|
satisfied = r.satisfied;
|
|
568
606
|
actual = r.actual;
|
|
607
|
+
probeError = undefined;
|
|
569
608
|
}
|
|
570
609
|
catch (err) {
|
|
610
|
+
probeError = err;
|
|
571
611
|
if (Date.now() < deadline) {
|
|
572
612
|
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
573
613
|
continue;
|
|
574
614
|
}
|
|
575
615
|
satisfied = false;
|
|
576
|
-
actual
|
|
616
|
+
// The recorded "actual" is what the timeline shows next to the
|
|
617
|
+
// matcher, so it gets the readable summary too — never the call-log
|
|
618
|
+
// wall a playwright timeout carries.
|
|
619
|
+
actual =
|
|
620
|
+
locatorFailureMessage(probe.label, err) ??
|
|
621
|
+
`<error: ${err?.message ?? String(err)}>`;
|
|
577
622
|
}
|
|
578
623
|
const passed = satisfied !== negated;
|
|
579
624
|
if (passed) {
|
|
@@ -590,7 +635,11 @@ function buildLocatorMatchers(loc, negated, message) {
|
|
|
590
635
|
return;
|
|
591
636
|
}
|
|
592
637
|
if (Date.now() >= deadline) {
|
|
593
|
-
|
|
638
|
+
// The deadline, not the stopwatch: every one of these polled until it
|
|
639
|
+
// ran out, and "(waited 5s)" is both what the author set and the same
|
|
640
|
+
// number twice in a row — a measured 4_987ms is neither.
|
|
641
|
+
const msg = (await elementFailure(probeError)) ??
|
|
642
|
+
`${describe(actual)} (waited ${formatWaited(budget)})`;
|
|
594
643
|
const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
|
|
595
644
|
recordAssertion({
|
|
596
645
|
matcher,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A wait's duration, in the units a reader thinks in: "800ms", "5s", "1.5s". */
|
|
2
|
+
export declare function formatWaited(ms: number): string;
|
|
3
|
+
/**
|
|
4
|
+
* The sentence to fail with, or `undefined` when `err` is not a locator
|
|
5
|
+
* timeout we understand (in which case the caller must leave it alone).
|
|
6
|
+
*
|
|
7
|
+
* `label` is the locator's human chain label, the same string the timeline
|
|
8
|
+
* step shows.
|
|
9
|
+
*/
|
|
10
|
+
export declare function locatorFailureMessage(label: string, err: unknown): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Replace a locator timeout's message in place and hand the error back, so the
|
|
13
|
+
* caller can `throw` it unchanged in every other respect.
|
|
14
|
+
*
|
|
15
|
+
* The error is mutated rather than wrapped: its stack holds the frames of the
|
|
16
|
+
* author's own call, which a fresh Error would lose. The stack string embeds
|
|
17
|
+
* the old message (it is built at construction), so that copy is rewritten
|
|
18
|
+
* too — otherwise the CLI's failure block would print the friendly message and
|
|
19
|
+
* then the timeout wall right under it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function rewriteLocatorError(err: unknown, label: string): unknown;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Readable failures for locator steps.
|
|
2
|
+
//
|
|
3
|
+
// Playwright reports every unmet actionability wait the same way: a
|
|
4
|
+
// `TimeoutError` whose message is "Timeout 5000ms exceeded." with the real
|
|
5
|
+
// story — did the element exist at all? was it disabled? did something cover
|
|
6
|
+
// it? — buried in a call log below it. A test that clicks a button that is not
|
|
7
|
+
// on the page therefore fails with a sentence about OUR deadline, which reads
|
|
8
|
+
// as an infrastructure problem and says nothing about the page.
|
|
9
|
+
//
|
|
10
|
+
// This module turns those into one sentence that names the element and what
|
|
11
|
+
// was wrong with it. Anything it does not recognise (a strict-mode violation,
|
|
12
|
+
// a closed page, an assertion of ours) is passed through untouched — the rule
|
|
13
|
+
// is that a message is only ever replaced when we have something better to
|
|
14
|
+
// say.
|
|
15
|
+
//
|
|
16
|
+
// The call log is read from the error's own `log` array (playwright attaches
|
|
17
|
+
// it) and falls back to parsing the message, since only the message survives
|
|
18
|
+
// a serialize/deserialize round trip.
|
|
19
|
+
/** A wait's duration, in the units a reader thinks in: "800ms", "5s", "1.5s". */
|
|
20
|
+
export function formatWaited(ms) {
|
|
21
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
22
|
+
return "0ms";
|
|
23
|
+
if (ms < 1000)
|
|
24
|
+
return `${Math.round(ms)}ms`;
|
|
25
|
+
// Rounded off the millisecond count, not through toFixed — 1450ms is
|
|
26
|
+
// "1.5s", and binary floating point renders 1.45 as 1.4.
|
|
27
|
+
return `${Math.round(ms / 100) / 10}s`;
|
|
28
|
+
}
|
|
29
|
+
/** Recognise playwright's actionability timeout and pull it apart. Returns
|
|
30
|
+
* `undefined` for every other error. */
|
|
31
|
+
function parseTimeout(err) {
|
|
32
|
+
const e = err;
|
|
33
|
+
if (!e || e.name !== "TimeoutError" || typeof e.message !== "string")
|
|
34
|
+
return undefined;
|
|
35
|
+
const m = /Timeout (\d+)ms exceeded/.exec(e.message);
|
|
36
|
+
if (!m)
|
|
37
|
+
return undefined;
|
|
38
|
+
const raw = Array.isArray(e.log)
|
|
39
|
+
? e.log.filter((l) => typeof l === "string")
|
|
40
|
+
: logFromMessage(e.message);
|
|
41
|
+
return { timeoutMs: Number(m[1]), log: raw.map(cleanLogLine).filter(Boolean) };
|
|
42
|
+
}
|
|
43
|
+
function logFromMessage(message) {
|
|
44
|
+
const at = message.indexOf("Call log:");
|
|
45
|
+
if (at === -1)
|
|
46
|
+
return [];
|
|
47
|
+
return message.slice(at + "Call log:".length).split("\n");
|
|
48
|
+
}
|
|
49
|
+
/** Strip a line's leading bullet and repeat count ("2 × waiting for …"). */
|
|
50
|
+
function cleanLogLine(line) {
|
|
51
|
+
return line.trim().replace(/^-\s*/, "").replace(/^\d+\s*×\s*/, "");
|
|
52
|
+
}
|
|
53
|
+
/** Did the locator ever match anything? Playwright logs "locator resolved to
|
|
54
|
+
* <html>" the moment it does, and nothing of the sort when it never did. */
|
|
55
|
+
function resolved(log) {
|
|
56
|
+
return log.some((l) => l.startsWith("locator resolved to"));
|
|
57
|
+
}
|
|
58
|
+
/** The state a `waitFor`-style wait was after, from its opening log line. */
|
|
59
|
+
function waitedForState(log) {
|
|
60
|
+
for (const line of log) {
|
|
61
|
+
const m = /^waiting for .* to be (attached|detached|visible|hidden)$/.exec(line);
|
|
62
|
+
if (m)
|
|
63
|
+
return m[1];
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Why an element that DID match still could not be acted on. Read from the
|
|
68
|
+
* last attempt, since that is the state the deadline caught it in. */
|
|
69
|
+
function actionabilityReason(log) {
|
|
70
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
71
|
+
const line = log[i];
|
|
72
|
+
// "element is not visible" / "not enabled" / "not stable" / "not editable"
|
|
73
|
+
const missing = /^element is not (.+)$/.exec(line);
|
|
74
|
+
if (missing)
|
|
75
|
+
return `is not ${missing[1]}`;
|
|
76
|
+
// "<div id="overlay"></div> intercepts pointer events"
|
|
77
|
+
const covered = /^(<.+>) intercepts pointer events$/.exec(line);
|
|
78
|
+
if (covered)
|
|
79
|
+
return `is covered by ${covered[1]}`;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The sentence to fail with, or `undefined` when `err` is not a locator
|
|
85
|
+
* timeout we understand (in which case the caller must leave it alone).
|
|
86
|
+
*
|
|
87
|
+
* `label` is the locator's human chain label, the same string the timeline
|
|
88
|
+
* step shows.
|
|
89
|
+
*/
|
|
90
|
+
export function locatorFailureMessage(label, err) {
|
|
91
|
+
const detail = parseTimeout(err);
|
|
92
|
+
if (!detail)
|
|
93
|
+
return undefined;
|
|
94
|
+
const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
|
|
95
|
+
if (!resolved(detail.log))
|
|
96
|
+
return `No element matches ${label} ${waited}`;
|
|
97
|
+
// It matched, so the failure is about the element's state.
|
|
98
|
+
switch (waitedForState(detail.log)) {
|
|
99
|
+
case "hidden":
|
|
100
|
+
return `Element ${label} is still visible ${waited}`;
|
|
101
|
+
case "detached":
|
|
102
|
+
return `Element ${label} is still attached to the page ${waited}`;
|
|
103
|
+
case "visible":
|
|
104
|
+
return `Element ${label} is not visible ${waited}`;
|
|
105
|
+
default:
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
const reason = actionabilityReason(detail.log);
|
|
109
|
+
return reason
|
|
110
|
+
? `Element ${label} ${reason} ${waited}`
|
|
111
|
+
: `Element ${label} never became ready for this action ${waited}`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Replace a locator timeout's message in place and hand the error back, so the
|
|
115
|
+
* caller can `throw` it unchanged in every other respect.
|
|
116
|
+
*
|
|
117
|
+
* The error is mutated rather than wrapped: its stack holds the frames of the
|
|
118
|
+
* author's own call, which a fresh Error would lose. The stack string embeds
|
|
119
|
+
* the old message (it is built at construction), so that copy is rewritten
|
|
120
|
+
* too — otherwise the CLI's failure block would print the friendly message and
|
|
121
|
+
* then the timeout wall right under it.
|
|
122
|
+
*/
|
|
123
|
+
export function rewriteLocatorError(err, label) {
|
|
124
|
+
const message = locatorFailureMessage(label, err);
|
|
125
|
+
if (message === undefined)
|
|
126
|
+
return err;
|
|
127
|
+
const e = err;
|
|
128
|
+
const old = e.message;
|
|
129
|
+
if (typeof e.stack === "string") {
|
|
130
|
+
for (const [header, replacement] of [
|
|
131
|
+
[`${e.name}: ${old}`, `${e.name}: ${message}`],
|
|
132
|
+
[old, message],
|
|
133
|
+
]) {
|
|
134
|
+
if (e.stack.startsWith(header)) {
|
|
135
|
+
e.stack = replacement + e.stack.slice(header.length);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
e.message = message;
|
|
141
|
+
return e;
|
|
142
|
+
}
|
package/dist/locator.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// author-facing call is exactly one recorded browser event (with its rrweb
|
|
23
23
|
// drain), whatever playwright work it composes underneath.
|
|
24
24
|
import { Buffer } from "node:buffer";
|
|
25
|
+
import { rewriteLocatorError } from "./locator-errors.js";
|
|
25
26
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
26
27
|
import { truncateUtf8 } from "./recorder.js";
|
|
27
28
|
/** Default deadline for a locator action/read's target to become actionable.
|
|
@@ -350,20 +351,31 @@ function lowerInputFiles(files) {
|
|
|
350
351
|
export function makeLocator(backend, strategy, chain) {
|
|
351
352
|
const label = chainLabel(chain);
|
|
352
353
|
const extend = (step) => makeLocator(backend, strategy, { steps: [...chain.steps, step] });
|
|
354
|
+
// Every terminal op runs through this: playwright's actionability timeouts
|
|
355
|
+
// say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
|
|
356
|
+
// log, so they are rewritten into a sentence naming the element and its
|
|
357
|
+
// state (see locator-errors.ts). Applied INSIDE `pageOp`, so the recorded
|
|
358
|
+
// step carries the readable message too — not just the thrown error.
|
|
359
|
+
const readable = async (fn) => {
|
|
360
|
+
try {
|
|
361
|
+
return await fn();
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
throw rewriteLocatorError(err, label);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
353
367
|
// One recorded event, result NOT wrapped (void/action).
|
|
354
|
-
const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain), page));
|
|
368
|
+
const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(() => fn(lower(page, chain), page)));
|
|
355
369
|
// One recorded event whose fields the action itself finishes filling in:
|
|
356
370
|
// `rec` is the very object the recorder spreads once `fn` resolves (the
|
|
357
371
|
// same mutate-in-flight seam `screenshot`'s artifact id rides), so the
|
|
358
372
|
// click family can stamp the point it acted on. See stampActionPoint.
|
|
359
373
|
const actAt = (action, fn) => {
|
|
360
374
|
const rec = { selector: label };
|
|
361
|
-
return backend.pageOp(action, rec, (page) => fn(lower(page, chain), rec));
|
|
375
|
+
return backend.pageOp(action, rec, (page) => readable(() => fn(lower(page, chain), rec)));
|
|
362
376
|
};
|
|
363
377
|
// One recorded event, result provenance-wrapped for expect().
|
|
364
|
-
const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain)), {
|
|
365
|
-
wrap: true,
|
|
366
|
-
});
|
|
378
|
+
const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(() => fn(lower(page, chain))), { wrap: true });
|
|
367
379
|
// Silent, non-recorded reads for expect(locator) matchers to poll.
|
|
368
380
|
const probe = {
|
|
369
381
|
label,
|
|
@@ -459,7 +471,7 @@ export function makeLocator(backend, strategy, chain) {
|
|
|
459
471
|
out.push(extend({ m: "nth", args: [i] }));
|
|
460
472
|
return out;
|
|
461
473
|
},
|
|
462
|
-
evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => lower(page, chain).evaluate(fn, arg), { wrap: true }),
|
|
474
|
+
evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => readable(() => lower(page, chain).evaluate(fn, arg)), { wrap: true }),
|
|
463
475
|
waitFor: (opts) => act("waitFor", {}, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
|
|
464
476
|
};
|
|
465
477
|
// Attach the silent-read probe under its symbol (kept off the typed literal
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** The redirect host. RFC 8252 §8.3 prefers the IP literal. Some
|
|
2
|
+
* authorization servers only special-case the name — see the landmine at
|
|
3
|
+
* the top of this file. */
|
|
4
|
+
export type LoopbackHost = "127.0.0.1" | "localhost";
|
|
5
|
+
/** Protected-resource metadata (RFC 9728), the subset MCP uses. */
|
|
6
|
+
export interface ProtectedResourceMetadata {
|
|
7
|
+
resource?: string;
|
|
8
|
+
authorization_servers?: string[];
|
|
9
|
+
scopes_supported?: string[];
|
|
10
|
+
bearer_methods_supported?: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Authorization-server metadata (RFC 8414), the subset we need. */
|
|
13
|
+
export interface AuthServerMetadata {
|
|
14
|
+
issuer: string;
|
|
15
|
+
authorization_endpoint: string;
|
|
16
|
+
token_endpoint: string;
|
|
17
|
+
registration_endpoint?: string;
|
|
18
|
+
scopes_supported?: string[];
|
|
19
|
+
code_challenge_methods_supported?: string[];
|
|
20
|
+
grant_types_supported?: string[];
|
|
21
|
+
}
|
|
22
|
+
/** What the client ended up with. Handed to the test, so it can assert on
|
|
23
|
+
* the grant and reuse the token elsewhere. */
|
|
24
|
+
export interface McpIdentity {
|
|
25
|
+
/** The bearer token. Redacted in every recorded event, never in this
|
|
26
|
+
* value — a test may need it to call the same API directly. */
|
|
27
|
+
accessToken: string;
|
|
28
|
+
refreshToken?: string;
|
|
29
|
+
tokenType: string;
|
|
30
|
+
/** What the server GRANTED, which is not always what was asked for. */
|
|
31
|
+
scopes: string[];
|
|
32
|
+
/** The RFC 8707 resource the token is bound to. */
|
|
33
|
+
resource?: string;
|
|
34
|
+
clientId: string;
|
|
35
|
+
issuer: string;
|
|
36
|
+
/** The redirect URI this grant was issued for. Kept so a test can prove
|
|
37
|
+
* which kind of client it just was — a loopback one, by default. */
|
|
38
|
+
redirectUri: string;
|
|
39
|
+
/** Where to spend the refresh token. Carried on the identity because a
|
|
40
|
+
* refresh usually happens in a forked test that never ran discovery,
|
|
41
|
+
* and an issuer's token endpoint is not derivable from its URL. */
|
|
42
|
+
tokenEndpoint: string;
|
|
43
|
+
/** Epoch milliseconds, when the server reported `expires_in`. */
|
|
44
|
+
expiresAt?: number;
|
|
45
|
+
}
|
|
46
|
+
/** The user (or the authorization server) refused the grant. */
|
|
47
|
+
export declare class McpAuthDeniedError extends Error {
|
|
48
|
+
readonly code: string;
|
|
49
|
+
readonly description?: string;
|
|
50
|
+
constructor(code: string, description?: string);
|
|
51
|
+
}
|
|
52
|
+
export interface AuthorizeOptions {
|
|
53
|
+
/** Skip dynamic registration and use a pre-registered client. */
|
|
54
|
+
clientId?: string;
|
|
55
|
+
/** For a confidential client. Sent with the token request. */
|
|
56
|
+
clientSecret?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Use this redirect URI instead of a loopback listener. Nothing is
|
|
59
|
+
* served for it — the browser lands somewhere this daemon does not own,
|
|
60
|
+
* so the test must hand the landed URL back:
|
|
61
|
+
* `await auth.complete({ url: page.url() })`.
|
|
62
|
+
*/
|
|
63
|
+
redirectUri?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Escape hatch. Scopes normally come from the protected-resource
|
|
66
|
+
* metadata, because that is what a real MCP client does — it does not
|
|
67
|
+
* ask its user which scopes to request. Set this only for a server that
|
|
68
|
+
* advertises none and still demands one.
|
|
69
|
+
*/
|
|
70
|
+
scopes?: string[];
|
|
71
|
+
/** Client name presented at dynamic registration and, usually, on the
|
|
72
|
+
* consent screen. */
|
|
73
|
+
clientName?: string;
|
|
74
|
+
/** Override the RFC 8707 resource. Defaults to the metadata's
|
|
75
|
+
* `resource`, else the MCP server URL. */
|
|
76
|
+
resource?: string;
|
|
77
|
+
/** Which loopback host to register. Defaults to `127.0.0.1`. */
|
|
78
|
+
loopbackHost?: LoopbackHost;
|
|
79
|
+
/** Budget for {@link Authorization.complete}. Default 120 s — a human
|
|
80
|
+
* flow driven by browser steps is slower than an HTTP call. */
|
|
81
|
+
timeoutMs?: number;
|
|
82
|
+
}
|
|
83
|
+
/** Everything discovery found, so a test can assert on it. */
|
|
84
|
+
export interface AuthorizationServerInfo {
|
|
85
|
+
issuer: string;
|
|
86
|
+
authorizationEndpoint: string;
|
|
87
|
+
tokenEndpoint: string;
|
|
88
|
+
registrationEndpoint?: string;
|
|
89
|
+
/** True when this client registered itself for this flow. */
|
|
90
|
+
dynamicallyRegistered: boolean;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* One authorization attempt, in progress.
|
|
94
|
+
*
|
|
95
|
+
* `authorize()` has already done discovery, registration and PKCE, and has
|
|
96
|
+
* bound the loopback listener. All that is left is the part with a human
|
|
97
|
+
* in it: the test navigates a browser to {@link url}, and then calls
|
|
98
|
+
* {@link complete}.
|
|
99
|
+
*/
|
|
100
|
+
export declare class Authorization {
|
|
101
|
+
/** Send the browser here. */
|
|
102
|
+
readonly url: string;
|
|
103
|
+
readonly redirectUri: string;
|
|
104
|
+
readonly state: string;
|
|
105
|
+
readonly clientId: string;
|
|
106
|
+
readonly server: AuthorizationServerInfo;
|
|
107
|
+
/** Scopes requested, which may be empty — see {@link AuthorizeOptions.scopes}. */
|
|
108
|
+
readonly scopes: string[];
|
|
109
|
+
readonly resource?: string;
|
|
110
|
+
private readonly verifier;
|
|
111
|
+
private readonly clientSecret?;
|
|
112
|
+
private readonly timeoutMs;
|
|
113
|
+
private readonly listener?;
|
|
114
|
+
private settled;
|
|
115
|
+
constructor(init: {
|
|
116
|
+
url: string;
|
|
117
|
+
redirectUri: string;
|
|
118
|
+
state: string;
|
|
119
|
+
clientId: string;
|
|
120
|
+
clientSecret?: string;
|
|
121
|
+
server: AuthorizationServerInfo;
|
|
122
|
+
scopes: string[];
|
|
123
|
+
resource?: string;
|
|
124
|
+
verifier: string;
|
|
125
|
+
timeoutMs: number;
|
|
126
|
+
listener?: LoopbackListener;
|
|
127
|
+
});
|
|
128
|
+
/**
|
|
129
|
+
* Wait for the redirect, then exchange the code for a token.
|
|
130
|
+
*
|
|
131
|
+
* With the default loopback redirect there is nothing to pass: the
|
|
132
|
+
* listener already holds the code, and this returns at once when the
|
|
133
|
+
* browser has landed. Pass `url` when the flow used a redirect URI this
|
|
134
|
+
* daemon does not serve — hand back where the browser ended up
|
|
135
|
+
* (`page.url()`).
|
|
136
|
+
*/
|
|
137
|
+
complete(opts?: {
|
|
138
|
+
url?: string;
|
|
139
|
+
timeoutMs?: number;
|
|
140
|
+
}): Promise<McpIdentity>;
|
|
141
|
+
/** Release the loopback port without finishing the flow. */
|
|
142
|
+
cancel(): void;
|
|
143
|
+
private waitForRedirect;
|
|
144
|
+
private exchange;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Do everything up to the browser: discovery, registration, PKCE, and the
|
|
148
|
+
* loopback listener. Returns the URL to send the user to.
|
|
149
|
+
*/
|
|
150
|
+
export declare function authorize(serverUrl: string, resourceMetadataUrl: string | undefined, opts?: AuthorizeOptions): Promise<Authorization>;
|
|
151
|
+
/** Exchange a refresh token for a new access token. Returns `undefined`
|
|
152
|
+
* when the identity has no refresh token to spend. */
|
|
153
|
+
export declare function refreshIdentity(identity: McpIdentity, clientSecret?: string): Promise<McpIdentity | undefined>;
|
|
154
|
+
/**
|
|
155
|
+
* Candidate well-known URLs for an issuer, in the order to try them.
|
|
156
|
+
*
|
|
157
|
+
* RFC 8414 inserts the well-known segment BEFORE the issuer's path:
|
|
158
|
+
* `https://host/tenant` is discovered at
|
|
159
|
+
* `https://host/.well-known/oauth-authorization-server/tenant`. OpenID
|
|
160
|
+
* Connect appends instead. A path-carrying issuer therefore has two valid
|
|
161
|
+
* spellings and servers differ on which they serve, so we try the RFC 8414
|
|
162
|
+
* order first and fall back.
|
|
163
|
+
*/
|
|
164
|
+
export declare function wellKnownUrls(issuer: string, suffix: string): string[];
|
|
165
|
+
/** The canonical resource identifier (RFC 8707): the server URL with no
|
|
166
|
+
* fragment, and a lowercase host. */
|
|
167
|
+
export declare function canonicalResource(serverUrl: string): string;
|
|
168
|
+
export declare function base64url(bytes: Uint8Array | string): string;
|
|
169
|
+
/** The loopback callback server. One per flow, stopped when the flow
|
|
170
|
+
* settles. */
|
|
171
|
+
interface LoopbackListener {
|
|
172
|
+
redirectUri: string;
|
|
173
|
+
wait(timeoutMs: number): Promise<URLSearchParams>;
|
|
174
|
+
stop(): void;
|
|
175
|
+
}
|
|
176
|
+
export {};
|