@specific.dev/spectest 0.55.0 → 0.56.1

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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.55.0",
3
+ "version": "0.56.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
- /** Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool). */
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
  *