The host-cloudflare Durable Object base already owned the approval store and the paused/resume RPCs, but its buildMcpServer ignored the session's elicitation mode (model-mode only) and the worker served no approval endpoints — so a gated MCP action could never be approved in the browser. - host-cloudflare: buildMcpServer wires browser elicitation mode + approvalUrl (origin from the create request, captured by the DO base) + the base's browserApprovalStore, mirroring cloud. The worker mounts an Access-gated /api/mcp-sessions/* handler that routes the paused (GET) + resume (POST) calls to the owning session's DO RPCs. - e2e: a new 'cloudflare' target — the real worker on workerd via wrangler dev (Miniflare) with local D1 + R2 and dev-auth, the console SPA built and served as static assets. The cross-target browser-approval scenario (approve + decline) now runs green here too, driving the rendered resume page against the worker. Browser approval is now covered end-to-end on all three deployment shapes: cloud, self-host, and Cloudflare self-host.
Unified · Split
| @@ -1,4 +1,5 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect } from "effect"; |
| 2 | import { HttpEffect, HttpRouter } from "effect/unstable/http"; | ||
| 2 | 3 | ||
| 3 | import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; | 4 | import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; |
| 4 | 5 | ||
| @@ -66,6 +67,14 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 61 unmodified lines | |||
| 66 | // store over the QuickJS engine. | 67 | // store over the QuickJS engine. |
| 67 | mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, | 68 | mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, |
| 68 | }, | 69 | }, |
| 70 | extensions: { | ||
| 71 | routes: [ | ||
| 72 | // Browser approval of paused MCP executions: the console resume page | ||
| 73 | // reads paused detail (GET) and records the decision (POST .../resume), | ||
| 74 | // Access-gated, routed to the owning session's Durable Object. | ||
| 75 | HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), | ||
| 76 | ], | ||
| 77 | }, | ||
| 69 | config: { mountPrefix: "/api", failure: textFailureStrategy }, | 78 | config: { mountPrefix: "/api", failure: textFailureStrategy }, |
| 70 | boot: identityLayer, | 79 | boot: identityLayer, |
| 71 | }); | 80 | }); |
| @@ -1,8 +1,16 @@ | |||
| 1 | import type { Layer } from "effect"; | 1 | import { Effect, type Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; | 3 | import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; |
| 4 | import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval"; | ||
| 5 | import type { | ||
| 6 | McpApprovalOwner, | ||
| 7 | McpSessionApprovalResult, | ||
| 8 | McpSessionResumeApprovalResult, | ||
| 9 | } from "@executor-js/cloudflare/mcp/durable-object"; | ||
| 10 | import type { ResumeResponse } from "@executor-js/execution"; | ||
| 4 | 11 | ||
| 5 | import type { CloudflareConfig, CloudflareEnv } from "../config"; | 12 | import type { CloudflareConfig, CloudflareEnv } from "../config"; |
| 13 | import { makeAccessVerifier } from "../auth/cloudflare-access"; | ||
| 6 | import { cloudflareAccessMcpAuth } from "./auth"; | 14 | import { cloudflareAccessMcpAuth } from "./auth"; |
| 7 | import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store"; | 15 | import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store"; |
| 8 | 16 | ||
| @@ -32,8 +40,103 @@ export interface CloudflareMcpSeams { · 23 unmodified lines | |||
| 32 | readonly sessions: Layer.Layer<McpSessionStore>; | 40 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 33 | /** Route 500 defects through the host's console `ErrorCapture`. */ | 41 | /** Route 500 defects through the host's console `ErrorCapture`. */ |
| 34 | readonly reporter: Layer.Layer<McpErrorReporter>; | 42 | readonly reporter: Layer.Layer<McpErrorReporter>; |
| 43 | /** | ||
| 44 | * The browser-approval HTTP handler, mounted by the app at | ||
| 45 | * `/api/mcp-sessions/*`: an Access-gated web handler that reads paused-execution | ||
| 46 | * detail (GET) and records the human's decision (POST `/resume`) for the console | ||
| 47 | * resume page, routing each to the owning session's Durable Object RPCs. | ||
| 48 | */ | ||
| 49 | readonly approvalHandler: (request: Request) => Promise<Response>; | ||
| 35 | } | 50 | } |
| 36 | 51 | ||
| 52 | // The MCP session Durable Object exposes the approval RPCs (the base class | ||
| 53 | // implements them); `@cloudflare/workers-types` types the stub generically, so | ||
| 54 | // narrow at this one boundary via a single `unknown`-param hop (same shape the | ||
| 55 | // session-store seam uses for the dispatch stub). | ||
| 56 | const toApprovalStub = (stub: unknown): McpApprovalStub => stub as McpApprovalStub; | ||
| 57 | |||
| 58 | interface McpApprovalStub { | ||
| 59 | getPausedExecutionForApproval( | ||
| 60 | executionId: string, | ||
| 61 | identity: McpApprovalOwner, | ||
| 62 | ): Promise<McpSessionApprovalResult>; | ||
| 63 | resumeExecutionForApproval( | ||
| 64 | executionId: string, | ||
| 65 | identity: McpApprovalOwner, | ||
| 66 | response: ResumeResponse, | ||
| 67 | ): Promise<McpSessionResumeApprovalResult>; | ||
| 68 | } | ||
| 69 | |||
| 70 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; | ||
| 71 | const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; | ||
| 72 | |||
| 73 | const jsonResponse = (value: unknown, status: number): Response => | ||
| 74 | new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); | ||
| 75 | |||
| 76 | /** | ||
| 77 | * Resolve the request to its Access principal (dev-auth → the fixed dev admin), | ||
| 78 | * then route the browser-approval call to the owning session's Durable Object — | ||
| 79 | * the same RPCs cloud serves through its HttpApi. The DO validates that the | ||
| 80 | * principal owns the session before reading or resuming. | ||
| 81 | */ | ||
| 82 | const makeCloudflareApprovalHandler = ( | ||
| 83 | config: CloudflareConfig, | ||
| 84 | env: CloudflareEnv, | ||
| 85 | ): ((request: Request) => Promise<Response>) => { | ||
| 86 | const { verify } = makeAccessVerifier(config); | ||
| 87 | const stubFor = (sessionId: string): McpApprovalStub => | ||
| 88 | toApprovalStub(env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId))); | ||
| 89 | |||
| 90 | return async (request) => { | ||
| 91 | const principal = await Effect.runPromise(verify(request)); | ||
| 92 | if (!principal) return jsonResponse({ error: "Unauthorized" }, 401); | ||
| 93 | const owner: McpApprovalOwner = { | ||
| 94 | accountId: principal.accountId, | ||
| 95 | organizationId: principal.organizationId, | ||
| 96 | }; | ||
| 97 | const { pathname } = new URL(request.url); | ||
| 98 | |||
| 99 | const paused = PAUSED_PATH.exec(pathname); | ||
| 100 | if (paused && request.method === "GET") { | ||
| 101 | const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval( | ||
| 102 | decodeURIComponent(paused[2]!), | ||
| 103 | owner, | ||
| 104 | ); | ||
| 105 | if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); | ||
| 106 | return jsonResponse({ text: result.text, structured: result.structured }, 200); | ||
| 107 | } | ||
| 108 | |||
| 109 | const resume = RESUME_PATH.exec(pathname); | ||
| 110 | if (resume && request.method === "POST") { | ||
| 111 | const raw = await Effect.runPromise( | ||
| 112 | Effect.tryPromise({ try: () => request.json(), catch: () => null }).pipe( | ||
| 113 | Effect.orElseSucceed(() => null), | ||
| 114 | ), | ||
| 115 | ); | ||
| 116 | const response = raw === null ? null : decodeResumeResponse(raw); | ||
| 117 | if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); | ||
| 118 | |||
| 119 | const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( | ||
| 120 | decodeURIComponent(resume[2]!), | ||
| 121 | owner, | ||
| 122 | response, | ||
| 123 | ); | ||
| 124 | if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); | ||
| 125 | return jsonResponse( | ||
| 126 | { | ||
| 127 | status: result.executionStatus, | ||
| 128 | text: result.text, | ||
| 129 | structured: result.structured, | ||
| 130 | isError: result.isError ?? false, | ||
| 131 | }, | ||
| 132 | 200, | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | |||
| 136 | return jsonResponse({ error: "Not found" }, 404); | ||
| 137 | }; | ||
| 138 | }; | ||
| 139 | |||
| 37 | /** | 140 | /** |
| 38 | * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable | 141 | * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable |
| 39 | * Object namespace. No per-session DB handle is threaded here — each session DO | 142 | * Object namespace. No per-session DB handle is threaded here — each session DO |
| @@ -46,4 +149,5 @@ export const makeCloudflareMcpSeams = ( · 6 unmodified lines | |||
| 46 | auth: cloudflareAccessMcpAuth(config), | 149 | auth: cloudflareAccessMcpAuth(config), |
| 47 | sessions: makeCloudflareMcpSessionStore(env), | 150 | sessions: makeCloudflareMcpSessionStore(env), |
| 48 | reporter: cloudflareMcpReporter, | 151 | reporter: cloudflareMcpReporter, |
| 152 | approvalHandler: makeCloudflareApprovalHandler(config, env), | ||
| 49 | }); | 153 | }); |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect } from "effect"; |
| 2 | 2 | ||
| 3 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | 3 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 4 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | ||
| 4 | import type { ExecutorDbHandle } from "@executor-js/api/server"; | 5 | import type { ExecutorDbHandle } from "@executor-js/api/server"; |
| 5 | import { | 6 | import { |
| 6 | McpSessionDOBase, | 7 | McpSessionDOBase, |
| @@ -68,6 +69,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 61 unmodified lines | |||
| 68 | dbHandle: CfSessionDbHandle, | 69 | dbHandle: CfSessionDbHandle, |
| 69 | ): Effect.Effect<BuiltMcpServer> { | 70 | ): Effect.Effect<BuiltMcpServer> { |
| 70 | const config = this.cfConfig; | 71 | const config = this.cfConfig; |
| 72 | const self = this; | ||
| 71 | return Effect.gen(function* () { | 73 | return Effect.gen(function* () { |
| 72 | // QuickJS-WASM must be loaded before the executor layer builds it (the | 74 | // QuickJS-WASM must be loaded before the executor layer builds it (the |
| 73 | // default variant can't fetch its .wasm on Workers). Idempotent per isolate. | 75 | // default variant can't fetch its .wasm on Workers). Idempotent per isolate. |
| @@ -77,7 +79,27 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 3 unmodified lines | |||
| 77 | sessionMeta.organizationId, | 79 | sessionMeta.organizationId, |
| 78 | sessionMeta.organizationName, | 80 | sessionMeta.organizationName, |
| 79 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); | 81 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); |
| 80 | const mcpServer = yield* createExecutorMcpServer({ engine }); | 82 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 83 | // approval RPCs): a gated execution pauses and returns an approvalUrl into | ||
| 84 | // the console resume page. The URL origin is the create request's origin | ||
| 85 | // (captured by the base), falling back to the configured site URL. | ||
| 86 | const elicitationMode = sessionMeta.elicitationMode ?? "model"; | ||
| 87 | const mcpServer = yield* createExecutorMcpServer({ | ||
| 88 | engine, | ||
| 89 | browserApprovalStore: self.browserApprovalStore, | ||
| 90 | elicitationMode: | ||
| 91 | elicitationMode === "browser" | ||
| 92 | ? { | ||
| 93 | mode: "browser" as const, | ||
| 94 | approvalUrl: (executionId) => | ||
| 95 | buildResumeApprovalUrl({ | ||
| 96 | origin: sessionMeta.webOrigin ?? config.webBaseUrl ?? "http://localhost", | ||
| 97 | executionId, | ||
| 98 | sessionId: self.sessionId, | ||
| 99 | }), | ||
| 100 | } | ||
| 101 | : { mode: elicitationMode }, | ||
| 102 | }); | ||
| 81 | return { mcpServer, engine } satisfies BuiltMcpServer; | 103 | return { mcpServer, engine } satisfies BuiltMcpServer; |
| 82 | }).pipe( | 104 | }).pipe( |
| 83 | Effect.withSpan("McpSessionDO.buildMcpServer"), | 105 | Effect.withSpan("McpSessionDO.buildMcpServer"), |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | |||
| 9 | "test:cloud": "vitest run --project cloud", | 9 | "test:cloud": "vitest run --project cloud", |
| 10 | "test:selfhost": "vitest run --project selfhost", | 10 | "test:selfhost": "vitest run --project selfhost", |
| 11 | "test:selfhost-docker": "vitest run --project selfhost-docker", | 11 | "test:selfhost-docker": "vitest run --project selfhost-docker", |
| 12 | "test:cloudflare": "vitest run --project cloudflare", | ||
| 12 | "test:watch": "vitest", | 13 | "test:watch": "vitest", |
| 13 | "ports": "bun scripts/ports.ts", | 14 | "ports": "bun scripts/ports.ts", |
| 14 | "summary": "bun scripts/summary.ts", | 15 | "summary": "bun scripts/summary.ts", |
| @@ -0,0 +1,64 @@ | |||
| 1 | // The Cloudflare host boot recipe: the REAL worker on workerd via `wrangler dev` | ||
| 2 | // (Miniflare) with a local D1 + R2 and dev-auth on. Shared by the vitest | ||
| 3 | // globalsetup (ephemeral) and, like the other hosts, available to a dev CLI. | ||
| 4 | // | ||
| 5 | // The browser scenarios drive the console `/resume` page, which the worker | ||
| 6 | // serves as Static Assets from `dist/` — so the SPA is built first (vite build, | ||
| 7 | // a couple of seconds) before wrangler serves it. | ||
| 8 | import { execFile } from "node:child_process"; | ||
| 9 | import { fileURLToPath } from "node:url"; | ||
| 10 | import { promisify } from "node:util"; | ||
| 11 | |||
| 12 | import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; | ||
| 13 | |||
| 14 | export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); | ||
| 15 | |||
| 16 | export interface CloudflareBootOptions { | ||
| 17 | readonly port: number; | ||
| 18 | readonly logFile?: string; | ||
| 19 | /** Skip the SPA build when `dist/` is already current (fast local iteration). */ | ||
| 20 | readonly skipBuild?: boolean; | ||
| 21 | } | ||
| 22 | |||
| 23 | export const bootCloudflare = async (options: CloudflareBootOptions): Promise<BootedProcesses> => { | ||
| 24 | if (!options.skipBuild) { | ||
| 25 | await promisify(execFile)("bun", ["run", "build"], { cwd: cloudflareDir }); | ||
| 26 | } | ||
| 27 | |||
| 28 | const procs = bootProcesses( | ||
| 29 | [ | ||
| 30 | { | ||
| 31 | // bunx resolves host-cloudflare's own wrangler. `--local` is the default; | ||
| 32 | // dev-auth + the secret key arrive as `--var` overrides so the worker | ||
| 33 | // needs no Cloudflare account or real Access app. | ||
| 34 | cmd: "bunx", | ||
| 35 | args: [ | ||
| 36 | "wrangler", | ||
| 37 | "dev", | ||
| 38 | "--port", | ||
| 39 | String(options.port), | ||
| 40 | "--ip", | ||
| 41 | "127.0.0.1", | ||
| 42 | "--var", | ||
| 43 | "ENABLE_DEV_AUTH:true", | ||
| 44 | "--var", | ||
| 45 | "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef", | ||
| 46 | ], | ||
| 47 | cwd: cloudflareDir, | ||
| 48 | env: { WRANGLER_SEND_METRICS: "false", CI: "true" }, | ||
| 49 | logFile: options.logFile, | ||
| 50 | }, | ||
| 51 | ], | ||
| 52 | { label: "cloudflare" }, | ||
| 53 | ); | ||
| 54 | |||
| 55 | try { | ||
| 56 | // dev-auth: /api/account/me answers 200 as the dev admin once the worker is | ||
| 57 | // up (workerd boot + esbuild + D1 schema bring-up take a beat on first run). | ||
| 58 | await waitForHttp(`http://127.0.0.1:${options.port}/api/account/me`, { timeoutMs: 120_000 }); | ||
| 59 | } catch (error) { | ||
| 60 | await procs.teardown(); | ||
| 61 | throw error; | ||
| 62 | } | ||
| 63 | return procs; | ||
| 64 | }; | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | // Boot the Cloudflare target: claim this checkout's port atomically, then run | ||
| 2 | // the shared boot recipe (cloudflare.boot.ts). Set E2E_CLOUDFLARE_URL to attach | ||
| 3 | // to an already-running instance instead. | ||
| 4 | import { claimPorts } from "../src/ports"; | ||
| 5 | import { waitForHttp } from "./boot"; | ||
| 6 | import { bootCloudflare } from "./cloudflare.boot"; | ||
| 7 | |||
| 8 | export default async function setup(): Promise<(() => Promise<void>) | void> { | ||
| 9 | if (process.env.E2E_CLOUDFLARE_URL) { | ||
| 10 | await waitForHttp(`${process.env.E2E_CLOUDFLARE_URL}/api/account/me`); | ||
| 11 | return; | ||
| 12 | } | ||
| 13 | |||
| 14 | const { ports, release } = await claimPorts([ | ||
| 15 | { envVar: "E2E_CLOUDFLARE_PORT", offset: 5, label: "cloudflare wrangler dev" }, | ||
| 16 | ]); | ||
| 17 | const port = ports.E2E_CLOUDFLARE_PORT!; | ||
| 18 | |||
| 19 | let procs; | ||
| 20 | try { | ||
| 21 | procs = await bootCloudflare({ port }); | ||
| 22 | } catch (error) { | ||
| 23 | await release(); | ||
| 24 | throw error; | ||
| 25 | } | ||
| 26 | return async () => { | ||
| 27 | await procs.teardown(); | ||
| 28 | await release(); | ||
| 29 | }; | ||
| 30 | } | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | // The Cloudflare self-host app (apps/host-cloudflare) as a target: the REAL | ||
| 2 | // worker on workerd via Miniflare (wrangler `unstable_dev`) with a local D1 + | ||
| 3 | // R2, booted in setup/cloudflare.globalsetup.ts. Dev-auth is on, so every | ||
| 4 | // request is the fixed dev admin — no per-identity login and no MCP OAuth (the | ||
| 5 | // /mcp endpoint accepts the dev principal directly). Single-tenant, like | ||
| 6 | // self-host; per-test isolation is the next step here. | ||
| 7 | import { Effect } from "effect"; | ||
| 8 | |||
| 9 | import { e2ePort } from "../src/ports"; | ||
| 10 | import type { Identity, Target } from "../src/target"; | ||
| 11 | |||
| 12 | // Offsets 0-4 are taken by cloud (0-3) and self-host (4); Cloudflare claims 5. | ||
| 13 | export const CLOUDFLARE_PORT = e2ePort("E2E_CLOUDFLARE_PORT", 5); | ||
| 14 | export const CLOUDFLARE_BASE_URL = | ||
| 15 | process.env.E2E_CLOUDFLARE_URL ?? `http://127.0.0.1:${CLOUDFLARE_PORT}`; | ||
| 16 | |||
| 17 | export const cloudflareTarget = (): Target => ({ | ||
| 18 | name: "cloudflare", | ||
| 19 | baseUrl: CLOUDFLARE_BASE_URL, | ||
| 20 | mcpUrl: `${CLOUDFLARE_BASE_URL}/mcp`, | ||
| 21 | // No "billing" and no setAccessTokenTtl (Cloudflare Access is the IdP; not | ||
| 22 | // test-adjustable). "mcp-oauth" advertises that the MCP surface exists — but | ||
| 23 | // dev-auth means it needs no consent flow, so `mcpConsent` is omitted and the | ||
| 24 | // MCP client connects as the dev admin directly. | ||
| 25 | capabilities: new Set(["api", "browser", "mcp-oauth"]), | ||
| 26 | // Dev-auth: one fixed admin. Empty `headers` makes the API surface send no | ||
| 27 | // auth (and skip the Better Auth sign-in path) — the worker resolves every | ||
| 28 | // request to the dev admin. No cookie is needed for the browser either. | ||
| 29 | newIdentity: () => Effect.succeed({ label: "dev-admin", headers: {} } satisfies Identity), | ||
| 30 | }); | ||
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | // vitest.config.ts + a globalsetup that boots (or attaches to) the instance. | 3 | // vitest.config.ts + a globalsetup that boots (or attaches to) the instance. |
| 4 | import type { Target } from "../src/target"; | 4 | import type { Target } from "../src/target"; |
| 5 | import { cloudTarget } from "./cloud"; | 5 | import { cloudTarget } from "./cloud"; |
| 6 | import { cloudflareTarget } from "./cloudflare"; | ||
| 6 | import { desktopTarget } from "./desktop"; | 7 | import { desktopTarget } from "./desktop"; |
| 7 | import { selfhostTarget } from "./selfhost"; | 8 | import { selfhostTarget } from "./selfhost"; |
| 8 | import { selfhostDockerTarget } from "./selfhost-docker"; | 9 | import { selfhostDockerTarget } from "./selfhost-docker"; |
| @@ -11,6 +12,7 @@ const factories: Record<string, () => Target> = { · 2 unmodified lines | |||
| 11 | cloud: cloudTarget, | 12 | cloud: cloudTarget, |
| 12 | selfhost: selfhostTarget, | 13 | selfhost: selfhostTarget, |
| 13 | "selfhost-docker": selfhostDockerTarget, | 14 | "selfhost-docker": selfhostDockerTarget, |
| 15 | cloudflare: cloudflareTarget, | ||
| 14 | desktop: desktopTarget, | 16 | desktop: desktopTarget, |
| 15 | }; | 17 | }; |
| 16 | 18 | ||
| @@ -38,6 +38,14 @@ export default defineConfig({ · 37 unmodified lines | |||
| 38 | include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"], | 38 | include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"], |
| 39 | fileParallelism: false, | 39 | fileParallelism: false, |
| 40 | }), | 40 | }), |
| 41 | // The Cloudflare self-host worker (workerd via wrangler dev, dev-auth). | ||
| 42 | // Scoped to the browser-approval scenario for now — the only cross-target | ||
| 43 | // scenario wired for this host; the rest of scenarios/** is not yet | ||
| 44 | // validated against the worker. Shares self-host's single-admin model. | ||
| 45 | project("cloudflare", { | ||
| 46 | include: ["scenarios/browser-approval.test.ts", "cloudflare/**/*.test.ts"], | ||
| 47 | fileParallelism: false, | ||
| 48 | }), | ||
| 41 | // The Electron desktop app. Only desktop/** scenarios — the desktop | 49 | // The Electron desktop app. Only desktop/** scenarios — the desktop |
| 42 | // target provides none of the standard surfaces (each scenario | 50 | // target provides none of the standard surfaces (each scenario |
| 43 | // launches its own app via Playwright's electron driver), so running | 51 | // launches its own app via Playwright's electron driver), so running |