@specific.dev/spectest 0.55.0 → 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 +4 -0
- 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 +69 -0
- 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/recorder.d.ts
CHANGED
|
@@ -599,6 +599,7 @@ export type StepBlock = {
|
|
|
599
599
|
value?: string;
|
|
600
600
|
error?: boolean;
|
|
601
601
|
}[];
|
|
602
|
+
label?: string;
|
|
602
603
|
} | {
|
|
603
604
|
type: "table";
|
|
604
605
|
columns: string[];
|
|
@@ -611,6 +612,10 @@ export type StepBlock = {
|
|
|
611
612
|
type: "chat";
|
|
612
613
|
messages: ChatBlockMessage[];
|
|
613
614
|
label?: string;
|
|
615
|
+
} | {
|
|
616
|
+
type: "details";
|
|
617
|
+
summary: string;
|
|
618
|
+
blocks: StepBlock[];
|
|
614
619
|
};
|
|
615
620
|
/** One message of a `chat` block. */
|
|
616
621
|
export interface ChatBlockMessage {
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -118,6 +118,10 @@ import {
|
|
|
118
118
|
} from "./harness/methods.js";
|
|
119
119
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
120
120
|
import { openTerminal } from "./terminal.js";
|
|
121
|
+
// `ctx.mcp(url)`. One client per call, no registry: an authenticated
|
|
122
|
+
// client is passed to descendants as a test's return value.
|
|
123
|
+
import { openMcp } from "./mcp.js";
|
|
124
|
+
import { setRawFetch } from "./harness/raw-fetch.js";
|
|
121
125
|
import { readAnnotation, type RenderAnnotation } from "./annotate.js";
|
|
122
126
|
import {
|
|
123
127
|
pauseRecording,
|
|
@@ -4178,6 +4182,10 @@ function isTransportError(err: unknown): boolean {
|
|
|
4178
4182
|
*/
|
|
4179
4183
|
function installFetchWrapper(): () => void {
|
|
4180
4184
|
const original = globalThis.fetch;
|
|
4185
|
+
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
4186
|
+
// wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
|
|
4187
|
+
// body to the end, which never finishes for an SSE stream.
|
|
4188
|
+
setRawFetch(original);
|
|
4181
4189
|
const wrappedFn = async (
|
|
4182
4190
|
input: Parameters<typeof fetch>[0],
|
|
4183
4191
|
init?: Parameters<typeof fetch>[1],
|
|
@@ -4934,6 +4942,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
4934
4942
|
openTerminal: recordedOpenTerminal,
|
|
4935
4943
|
browser: trackedOpenBrowser,
|
|
4936
4944
|
mobile: trackedOpenMobile,
|
|
4945
|
+
mcp: openMcp,
|
|
4937
4946
|
testName: testCase.name,
|
|
4938
4947
|
parent,
|
|
4939
4948
|
};
|
|
@@ -5455,6 +5464,7 @@ async function evalCode(
|
|
|
5455
5464
|
openTerminal: evalOpenTerminal,
|
|
5456
5465
|
browser: trackedOpenBrowser,
|
|
5457
5466
|
mobile: trackedOpenMobile,
|
|
5467
|
+
mcp: openMcp,
|
|
5458
5468
|
testName: "eval",
|
|
5459
5469
|
parent: undefined,
|
|
5460
5470
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
|
|
23
|
+
let original: typeof fetch | undefined;
|
|
24
|
+
|
|
25
|
+
/** Called by the daemon with the real `fetch`, before it installs its
|
|
26
|
+
* instrumented one. */
|
|
27
|
+
export function setRawFetch(fn: typeof fetch): void {
|
|
28
|
+
original = fn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `fetch`, never the instrumented wrapper. Falls back to the global for
|
|
32
|
+
* a context where nothing was ever installed (unit tests, a plain
|
|
33
|
+
* script). */
|
|
34
|
+
export function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
35
|
+
return (original ?? globalThis.fetch)(input as RequestInfo, init);
|
|
36
|
+
}
|
package/src/ids.ts
CHANGED
|
@@ -44,7 +44,18 @@ export function generateId(prefix: string): string {
|
|
|
44
44
|
return `${prefix}_0${base32Crockford(digest.subarray(0, 16)).slice(0, RANDOM_LEN)}`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool).
|
|
49
|
+
*
|
|
50
|
+
* Exported because every in-VM secret with a uniqueness requirement has
|
|
51
|
+
* the same problem as an id: Bun's pool is frozen into the snapshot, so
|
|
52
|
+
* sibling forks draw identical bytes. An OAuth PKCE verifier and `state`
|
|
53
|
+
* (`mcp-auth.ts`) must come from here, not from `crypto.getRandomValues`.
|
|
54
|
+
*/
|
|
55
|
+
export function secureRandomBytes(n: number): Uint8Array {
|
|
56
|
+
return urandom(n);
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
function urandom(n: number): Uint8Array {
|
|
49
60
|
const buf = new Uint8Array(n);
|
|
50
61
|
try {
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,34 @@ export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./br
|
|
|
66
66
|
|
|
67
67
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
68
68
|
|
|
69
|
+
// The MCP client (`ctx.mcp`). Drives a Model Context Protocol server the
|
|
70
|
+
// way a real AI client would, with the OAuth flow a test runs through its
|
|
71
|
+
// own browser. See `mcp.ts`.
|
|
72
|
+
export {
|
|
73
|
+
McpHttpError,
|
|
74
|
+
McpRpcError,
|
|
75
|
+
McpAuthDeniedError,
|
|
76
|
+
type Mcp,
|
|
77
|
+
type McpOptions,
|
|
78
|
+
type McpAuthorization,
|
|
79
|
+
type McpChallenge,
|
|
80
|
+
type McpIdentity,
|
|
81
|
+
type McpToolResult,
|
|
82
|
+
type McpToolInfo,
|
|
83
|
+
type McpResourceInfo,
|
|
84
|
+
type McpResourceContents,
|
|
85
|
+
type McpPromptInfo,
|
|
86
|
+
type McpPromptResult,
|
|
87
|
+
type McpContent,
|
|
88
|
+
type McpNotification,
|
|
89
|
+
type McpSamplingRequest,
|
|
90
|
+
type McpSamplingResult,
|
|
91
|
+
type AuthorizeOptions,
|
|
92
|
+
type AuthorizationServerInfo,
|
|
93
|
+
} from "./mcp.js";
|
|
94
|
+
|
|
95
|
+
import type { Mcp, McpOptions } from "./mcp.js";
|
|
96
|
+
|
|
69
97
|
// Playwright-native locators — the select-then-act surface shared by
|
|
70
98
|
// `ctx.browser()` and `ctx.mobile()`. `Locator` mirrors playwright-core's
|
|
71
99
|
// Locator (getBy*/filter/first/nth/click/fill/textContent/…, STRICT mode).
|
|
@@ -1498,6 +1526,47 @@ export interface TestContext<
|
|
|
1498
1526
|
* natural way to assert on the exit code.
|
|
1499
1527
|
*/
|
|
1500
1528
|
openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
|
|
1529
|
+
/**
|
|
1530
|
+
* Connect to a Model Context Protocol server over HTTP, as an AI client
|
|
1531
|
+
* would. Every tool call, resource read and prompt renders as its own
|
|
1532
|
+
* step in the timeline, with the arguments and the result.
|
|
1533
|
+
*
|
|
1534
|
+
* ```ts
|
|
1535
|
+
* const mcp = await ctx.mcp("https://api.test/mcp");
|
|
1536
|
+
* const res = await mcp.call("create_invoice", { customer: "Acme" });
|
|
1537
|
+
* expect(res.json<{ id: string }>().id).toMatch(/^inv_/);
|
|
1538
|
+
* ```
|
|
1539
|
+
*
|
|
1540
|
+
* **Each call returns a NEW client.** There is no per-name registry like
|
|
1541
|
+
* `ctx.browser("alice")` has, and no default session. A client that has
|
|
1542
|
+
* authenticated is handed to the tests that need it, as this test's
|
|
1543
|
+
* return value — `ctx.parent` crosses the fork in daemon memory, live
|
|
1544
|
+
* connections included:
|
|
1545
|
+
*
|
|
1546
|
+
* ```ts
|
|
1547
|
+
* export const signedIn = env.test("sign in", async (ctx) => {
|
|
1548
|
+
* const mcp = await ctx.mcp(URL);
|
|
1549
|
+
* const auth = await mcp.authorize();
|
|
1550
|
+
* const page = await ctx.browser();
|
|
1551
|
+
* await page.goto(auth.url);
|
|
1552
|
+
* await page.getByRole("button", { name: "Allow" }).click();
|
|
1553
|
+
* await auth.complete();
|
|
1554
|
+
* return mcp;
|
|
1555
|
+
* });
|
|
1556
|
+
*
|
|
1557
|
+
* env.test("call a tool", { dependsOn: signedIn }, async (ctx) => {
|
|
1558
|
+
* await ctx.parent.call("create_invoice", { amount: 250 });
|
|
1559
|
+
* });
|
|
1560
|
+
* ```
|
|
1561
|
+
*
|
|
1562
|
+
* Two identities are two calls to `ctx.mcp` — nothing to name.
|
|
1563
|
+
*
|
|
1564
|
+
* A server that needs authorization answers `401`, which is reported and
|
|
1565
|
+
* not hidden: `ctx.mcp(url)` returns an unauthenticated client carrying
|
|
1566
|
+
* the challenge, and a call on it fails with the server's own rejection.
|
|
1567
|
+
* That is what makes "this tool is protected" a test.
|
|
1568
|
+
*/
|
|
1569
|
+
mcp(url: string, opts?: McpOptions): Promise<Mcp>;
|
|
1501
1570
|
/**
|
|
1502
1571
|
* Open the headless browser. Backed by Chromium-over-CDP inside the VM.
|
|
1503
1572
|
*
|