From 5a5f731359db4627741ee2970e9a8a22a991dd42 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 20:51:21 +0000 Subject: [PATCH 1/7] feat(test): describe browser app proof surfaces Expose normalized MCP App descriptors from the same compiler preparation so browser pools can compile honest app evidence without reloading project configuration. --- packages/agent-bundle/src/test/index.ts | 2 + packages/agent-bundle/src/test/manifest.ts | 76 +++++++++++++++++-- .../tests/test-harness-manifest.test.ts | 51 ++++++++++++- 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index d214a3c45..3a35435e9 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -17,6 +17,7 @@ * for them. */ export { + BROWSER_APP_PROOF_LEVEL, CLI_DISPATCH_PROOF_LEVEL, MCP_IN_MEMORY_PROOF_LEVEL, PACKED_STDIO_PROOF_LEVEL, @@ -30,6 +31,7 @@ export type { AgentTestProofLevel, CompileTestManifestOptions, TestManifestPluginIdentity, + TestableAppDescriptor, TestableRouteDescriptor, } from './manifest.ts'; export { AGENT_TEST_REGISTRY_VERSION, registerTestRoutes, testManifest } from './registry.ts'; diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index 0f557c63a..8ae853149 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -1,7 +1,8 @@ -import { resolve } from 'node:path'; +import { relative, resolve, sep } from 'node:path'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; +import type { NormalizedMcpApp } from '../core/types.ts'; import type { CompiledAgentRoute, CompiledCliCommand, @@ -28,15 +29,20 @@ import type { * spawns the generated stdio entry as a real process, and drives it with a * real MCP client. This is the only level here that is process evidence. * - * Browser and deleted-source artifact levels are later stages; nothing here - * stands in for them. + * - `browser-app` compiles MCP App HTML through the production Rsbuild + * profile and mounts it over the product bridge in a real browser page. It + * does not prove host embedding, a packed artifact, or Workbench behavior. + * + * Deleted-source artifact evidence is a later stage; nothing here stands in + * for it. */ -export type AgentTestProofLevel = 'route-unit' | 'mcp-in-memory' | 'cli-dispatch' | 'packed-stdio'; +export type AgentTestProofLevel = 'route-unit' | 'mcp-in-memory' | 'cli-dispatch' | 'packed-stdio' | 'browser-app'; export const ROUTE_UNIT_PROOF_LEVEL = 'route-unit' as const; export const MCP_IN_MEMORY_PROOF_LEVEL = 'mcp-in-memory' as const; export const CLI_DISPATCH_PROOF_LEVEL = 'cli-dispatch' as const; export const PACKED_STDIO_PROOF_LEVEL = 'packed-stdio' as const; +export const BROWSER_APP_PROOF_LEVEL = 'browser-app' as const; /** * One line per level, printed in every harness failure. A red test has to @@ -53,6 +59,8 @@ export const proofLevelLabel = (level: AgentTestProofLevel): string => { return 'cli-dispatch (argv dispatched through the routed CLI shell in-process; NOT a spawned binary)'; case 'packed-stdio': return 'packed-stdio (packed tarball installed into a clean consumer, generated stdio entry spawned as a real process)'; + case 'browser-app': + return 'browser-app (MCP App HTML compiled through the production Rsbuild profile, mounted in a real browser page over the product bridge; NOT host embedding, packed-artifact, or Workbench evidence)'; default: { const exhaustive: never = level; throw new TypeError(`Unknown proof level ${String(exhaustive)}.`); @@ -80,13 +88,33 @@ export interface TestManifestPluginIdentity { readonly version: string; } +/** One normalized MCP App declaration addressable by the browser proof level. */ +export interface TestableAppDescriptor { + readonly _meta?: Readonly>; + readonly id: string; + readonly name: string; + readonly prebuilt?: true; + /** Project-relative POSIX path of the browser entry module. */ + readonly relativePath: string; + readonly resourceUri: string; + /** Every MCP server sharing this identical app declaration. */ + readonly serverIds: readonly string[]; + /** Absolute browser entry module path. */ + readonly source: string; + readonly targets: readonly string[]; + /** Absolute optional HTML shell template path. */ + readonly template?: string; +} + /** * What the compiler tells the test harness about one project: the route - * inventory, the project identity its generated servers advertise, the - * selected targets, and the compiler's own diagnostics. Browser-App and - * artifact descriptors arrive with the stages that can honestly prove them. + * and MCP App inventories, the project identity its generated servers + * advertise, the selected targets, and the compiler's own diagnostics. + * Artifact descriptors arrive with the stage that can honestly prove them. */ export interface AgentBundleTestManifest { + /** Collision-checked MCP App declarations from the same compiler pass. */ + readonly apps: Readonly>; /** * The collision-checked routed-CLI command graph (#102 stage 2) from the * same pass, so the CLI dispatch level drives the product's own dispatcher @@ -131,12 +159,44 @@ const graphRoutes = (graph: CompiledRouteGraph): readonly CompiledAgentRoute[] = ...graph.servers.flatMap((server) => server.routes), ]; +const appDescriptors = ( + apps: readonly NormalizedMcpApp[], + projectRoot: string, +): Readonly> => { + const descriptors: Record = {}; + for (const app of apps) { + const existing = descriptors[app.name]; + if (existing !== undefined) { + descriptors[app.name] = { + ...existing, + serverIds: [...new Set([...existing.serverIds, app.serverId])].sort((left, right) => left.localeCompare(right)), + targets: [...new Set([...existing.targets, ...app.targets])], + }; + continue; + } + descriptors[app.name] = { + ...(app._meta === undefined ? {} : { _meta: app._meta }), + id: app.id, + name: app.name, + ...(app.prebuilt === undefined ? {} : { prebuilt: app.prebuilt }), + relativePath: relative(projectRoot, app.source).split(sep).join('/'), + resourceUri: app.resourceUri, + serverIds: [app.serverId], + source: app.source, + targets: [...app.targets], + ...(app.template === undefined ? {} : { template: app.template }), + }; + } + return descriptors; +}; + /** * Projects the compiled route graph into the manifest the harness addresses. * The graph is an input here, never recompiled: one compiler pass feeds the * build, `inspect`, and the harness alike. */ export const testManifestFromRouteGraph = (input: { + readonly apps?: readonly NormalizedMcpApp[]; readonly configPath?: string; readonly diagnostics?: readonly Diagnostic[]; readonly graph: CompiledRouteGraph; @@ -147,6 +207,7 @@ export const testManifestFromRouteGraph = (input: { const routes: Record = {}; for (const route of graphRoutes(input.graph)) routes[route.id] = descriptorOf(route); return deepFreeze({ + apps: appDescriptors(input.apps ?? [], input.projectRoot), cliCommands: [...(input.graph.cli?.commands ?? [])], ...(input.configPath === undefined ? {} : { configPath: input.configPath }), diagnostics: [...(input.diagnostics ?? input.graph.diagnostics)], @@ -182,6 +243,7 @@ export const compileTestManifest = async ( root, }).prepare('inspect'); return testManifestFromRouteGraph({ + apps: prepared.model?.mcpApps ?? [], configPath: prepared.configPath, diagnostics: prepared.diagnostics, graph: prepared.routeGraph ?? emptyCompiledRouteGraph, diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index b4094ff31..7330d5c61 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from '@rstest/core'; import { routeTestSetupSource } from '../src/rstest/setup-module.ts'; import { AgentTestError } from '../src/test/errors.ts'; import { invokeCli } from '../src/test/cli.ts'; -import { compileTestManifest, testManifestFromRouteGraph } from '../src/test/manifest.ts'; +import { compileTestManifest, proofLevelLabel, testManifestFromRouteGraph } from '../src/test/manifest.ts'; import { AGENT_TEST_REGISTRY_SYMBOL_KEY, AGENT_TEST_REGISTRY_VERSION, @@ -39,6 +39,12 @@ const withRealmRegistry = async (registry: unknown, body: () => T | Promise { + it('names browser-app as compiled browser evidence without overstating host or artifact proof', () => { + expect(proofLevelLabel('browser-app')).toBe( + 'browser-app (MCP App HTML compiled through the production Rsbuild profile, mounted in a real browser page over the product bridge; NOT host embedding, packed-artifact, or Workbench evidence)', + ); + }); + it('names every conventional route the compiler discovered, with its extracted config', () => { expect(Object.keys(manifest.routes).sort()).toEqual([ 'app:harness/panel', @@ -66,6 +72,17 @@ describe('the compiled test manifest', () => { expect(manifest.proofLevel).toBe('route-unit'); expect(manifest.projectRoot).toBe(fixtureRoot); expect(manifest.targets).toEqual(['claude']); + expect(manifest.apps).toEqual({ + panel: { + id: 'mcp-app:harness:panel', + name: 'panel', + relativePath: 'src/mcp/harness/apps/panel.tsx', + resourceUri: 'ui://harness/panel', + serverIds: ['mcp:harness'], + source: resolve(fixtureRoot, 'src/mcp/harness/apps/panel.tsx'), + targets: ['claude'], + }, + }); }); it('carries the compiled command graph the CLI dispatch level dispatches over', () => { @@ -103,11 +120,40 @@ describe('the compiled test manifest', () => { it('projects an already-compiled graph without touching the filesystem again', async () => { const graph = await compileRouteGraph(fixtureRoot, { targets: ['claude'] } as never); - const projected = testManifestFromRouteGraph({ graph, projectRoot: fixtureRoot, targets: ['claude'] }); + const projected = testManifestFromRouteGraph({ + apps: [{ + id: 'mcp-app:harness:panel', + name: 'panel', + provenance: { kind: 'config', sourcePath: resolve(fixtureRoot, 'agent-bundle.config.ts') }, + resourceUri: 'ui://harness/panel.html', + serverId: 'mcp:harness', + serverName: 'harness', + source: resolve(fixtureRoot, 'views/panel.ts'), + targets: ['claude'], + template: resolve(fixtureRoot, 'views/panel.html'), + }], + graph, + projectRoot: fixtureRoot, + targets: ['claude'], + }); expect(projected.routes).toEqual(manifest.routes); + expect(projected.apps).toEqual({ + panel: { + id: 'mcp-app:harness:panel', + name: 'panel', + relativePath: 'views/panel.ts', + resourceUri: 'ui://harness/panel.html', + serverIds: ['mcp:harness'], + source: resolve(fixtureRoot, 'views/panel.ts'), + targets: ['claude'], + template: resolve(fixtureRoot, 'views/panel.html'), + }, + }); + expect(Object.isFrozen(projected.apps.panel)).toBe(true); expect(Object.isFrozen(projected.routes)).toBe(true); }); + }); describe('the generated route registry', () => { @@ -401,6 +447,7 @@ describe('the manifest a route-free project compiles', () => { }); expect(empty.routes).toEqual({}); + expect(empty.apps).toEqual({}); expect(empty.proofLevel).toBe('route-unit'); expect(empty.targets).toEqual(['portable']); }); From 63f5a966b90e836413f876a9caca552d81fab8fd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:01:16 +0000 Subject: [PATCH 2/7] feat(rstest): compile browser app test pools Compile every declared app in one production Rsbuild run and generate a bounded browser-worker registry with per-app target provenance. --- packages/agent-bundle/src/build/mcp-apps.ts | 36 +++-- .../src/rstest/browser-setup-module.ts | 60 +++++++ packages/agent-bundle/src/rstest/browser.ts | 151 ++++++++++++++++++ packages/agent-bundle/src/rstest/index.ts | 6 + .../agent-bundle/src/test/browser-registry.ts | 20 +++ .../tests/test-browser-rstest.test.ts | 62 +++++++ 6 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 packages/agent-bundle/src/rstest/browser-setup-module.ts create mode 100644 packages/agent-bundle/src/rstest/browser.ts create mode 100644 packages/agent-bundle/src/test/browser-registry.ts create mode 100644 packages/agent-bundle/tests/test-browser-rstest.test.ts diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index e03e7a7b8..e5d0140b7 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -90,12 +90,26 @@ const appIdentity = (app: NormalizedMcpApp): string => stableJson({ ...(app.template === undefined ? {} : { template: app.template }), }); +export type McpAppTargetSelection = + | Readonly<{ readonly target: string; readonly targets?: never }> + | Readonly<{ readonly target?: never; readonly targets: Readonly> }>; + +const selectedAppTarget = ( + app: NormalizedMcpApp, + selection: McpAppTargetSelection, +): string | undefined => { + const target = selection.target ?? selection.targets[app.id]; + return target !== undefined && app.targets.includes(target) ? target : undefined; +}; + export const planCompiledMcpApps = ( apps: readonly NormalizedMcpApp[], - options: { readonly outDir: string; readonly target: string }, + options: Readonly<{ readonly outDir: string } & McpAppTargetSelection>, ): readonly CompiledMcpApp[] => { - const planned = new Map(); - for (const app of apps.filter((candidate) => candidate.prebuilt !== true && candidate.targets.includes(options.target))) { + const planned = new Map(); + for (const app of apps) { + const target = selectedAppTarget(app, options); + if (app.prebuilt === true || target === undefined) continue; const identity = appIdentity(app); const existing = planned.get(app.name); if (existing !== undefined) { @@ -108,9 +122,9 @@ export const planCompiledMcpApps = ( if (!existing.serverIds.includes(app.serverId)) existing.serverIds.push(app.serverId); continue; } - planned.set(app.name, { app, identity, serverIds: [app.serverId] }); + planned.set(app.name, { app, identity, serverIds: [app.serverId], target }); } - return Object.freeze([...planned.values()].map(({ app, serverIds }) => Object.freeze({ + return Object.freeze([...planned.values()].map(({ app, serverIds, target }) => Object.freeze({ ...(app._meta === undefined ? {} : { _meta: app._meta }), id: app.id, mimeType: mcpAppMimeType, @@ -124,7 +138,7 @@ export const planCompiledMcpApps = ( app.source, ...(app.template === undefined ? [] : [app.template]), ]), - target: options.target, + target, }))); }; @@ -182,14 +196,16 @@ export const composeMcpAppsRsbuildConfig = ( export const compileMcpApps = async ( apps: readonly NormalizedMcpApp[], - options: { + options: Readonly<{ readonly cwd: string; readonly outDir: string; - readonly target: string; readonly tools?: AgentBundleToolsConfig; - }, + } & McpAppTargetSelection>, ): Promise => { - const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, target: options.target }); + const compiled = planCompiledMcpApps(apps, { + outDir: options.outDir, + ...(options.target === undefined ? { targets: options.targets } : { target: options.target }), + }); if (compiled.length === 0) { return compiled; } diff --git a/packages/agent-bundle/src/rstest/browser-setup-module.ts b/packages/agent-bundle/src/rstest/browser-setup-module.ts new file mode 100644 index 000000000..5819bd94b --- /dev/null +++ b/packages/agent-bundle/src/rstest/browser-setup-module.ts @@ -0,0 +1,60 @@ +import { Buffer } from 'node:buffer'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import type { CompiledMcpApp } from '../build/mcp-apps.ts'; +import { MAX_APP_HTML_BYTES } from '../dev/mcp-apps/mcp-app-bridge.ts'; +import { + AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY, + AGENT_BROWSER_TEST_REGISTRY_VERSION, + type AgentBrowserTestRegistry, + type CompiledBrowserTestApp, +} from '../test/browser-registry.ts'; +import { BROWSER_APP_PROOF_LEVEL, proofLevelLabel } from '../test/manifest.ts'; + +const compiledEntry = async (app: CompiledMcpApp): Promise => { + const html = await readFile(app.output, 'utf8'); + const bytes = Buffer.byteLength(html, 'utf8'); + if (bytes > MAX_APP_HTML_BYTES) { + throw new Error([ + `Compiled MCP App HTML exceeds the ${String(MAX_APP_HTML_BYTES)} byte browser harness bound.`, + ` proof level: ${proofLevelLabel(BROWSER_APP_PROOF_LEVEL)}`, + ` app: ${app.name} (${app.resourceUri}, target ${app.target})`, + ` output: ${app.output}`, + ` bytes: ${String(bytes)}`, + ].join('\n')); + } + return Object.freeze({ + html, + name: app.name, + output: app.output, + proofLevel: BROWSER_APP_PROOF_LEVEL, + resourceUri: app.resourceUri, + serverIds: [...app.serverIds], + target: app.target, + }); +}; + +export const browserTestSetupSource = (registry: AgentBrowserTestRegistry): string => [ + '// @generated by agent-bundle/rstest. Do not edit: rerun Rstest to regenerate.', + `const registry = ${JSON.stringify(registry)};`, + `globalThis[Symbol.for(${JSON.stringify(AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY)})] = registry;`, + '', +].join('\n'); + +export const writeBrowserTestSetup = async ( + projectRoot: string, + compiled: readonly CompiledMcpApp[], +): Promise => { + const apps = Object.fromEntries( + await Promise.all(compiled.map(async (app) => [app.name, await compiledEntry(app)] as const)), + ); + const registry: AgentBrowserTestRegistry = Object.freeze({ + apps, + version: AGENT_BROWSER_TEST_REGISTRY_VERSION, + }); + const target = resolve(projectRoot, '.agent-bundle', 'test', 'browser-app-setup.mjs'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, browserTestSetupSource(registry), 'utf8'); + return target; +}; diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts new file mode 100644 index 000000000..a4d5d2785 --- /dev/null +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -0,0 +1,151 @@ +import { existsSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { compileMcpApps } from '../build/mcp-apps.ts'; +import type { NormalizedMcpApp } from '../core/types.ts'; +import { compileTestManifest, proofLevelLabel, type TestableAppDescriptor } from '../test/manifest.ts'; +import { writeBrowserTestSetup } from './browser-setup-module.ts'; + +const browserAppInclude = 'tests/browser-app/**/*.test.{ts,tsx}'; + +export interface AgentBundleBrowserRstestOptions { + /** Explicit Agent Bundle configuration path; discovered from `root` when omitted. */ + readonly configPath?: string; + /** Test files for the browser-app level; defaults to `tests/browser-app/**`. */ + readonly include?: readonly string[]; + /** Project root; defaults to the working directory Rstest was started in. */ + readonly root?: string; + /** Extra setup files, appended after the generated browser registry. */ + readonly setupFiles?: readonly string[]; + /** + * Compiles every app for this target. By default each app uses its first + * declared target that is also selected by the project. + */ + readonly target?: string; +} + +export interface AgentBundleBrowserRstestConfig { + browser: { + enabled: true; + headless: true; + provider: 'playwright'; + providerOptions: { launch: { channel: 'chrome' } }; + viewport: { height: 900; width: 1440 }; + }; + include: string[]; + setupFiles: string[]; + source?: { tsconfigPath: string }; + tools: { + rspack: { + resolve: { + extensionAlias: { '.js': string[]; '.jsx': string[] }; + }; + }; + swc: { jsc: { transform: { react: { runtime: 'automatic' } } } }; + }; +} + +const appTarget = ( + app: TestableAppDescriptor, + projectTargets: readonly string[], + override: string | undefined, + configPath: string, +): string => { + const target = override ?? app.targets.find((candidate) => projectTargets.includes(candidate)); + if (target === undefined || !projectTargets.includes(target) || !app.targets.includes(target)) { + throw new Error([ + `MCP App ${JSON.stringify(app.name)} has no browser compilation target selected by the project.`, + ` proof level: ${proofLevelLabel('browser-app')}`, + ` app: ${app.name} (${app.resourceUri})`, + ` config: ${configPath}`, + ` app targets: ${app.targets.join(', ') || 'none'}`, + ` selected: ${projectTargets.join(', ') || 'none'}`, + ...(override === undefined ? [] : [` override: ${override}`]), + ].join('\n')); + } + return target; +}; + +const normalizedApp = (app: TestableAppDescriptor, configPath: string): NormalizedMcpApp => { + const serverId = app.serverIds[0]; + if (serverId === undefined) { + throw new Error(`MCP App ${JSON.stringify(app.name)} has no owning server in ${JSON.stringify(configPath)}.`); + } + return { + ...(app._meta === undefined ? {} : { _meta: app._meta }), + id: app.id, + name: app.name, + ...(app.prebuilt === undefined ? {} : { prebuilt: app.prebuilt }), + provenance: { kind: 'config', sourcePath: configPath }, + resourceUri: app.resourceUri, + serverId, + serverName: serverId.startsWith('mcp:') ? serverId.slice(4) : serverId, + source: app.source, + targets: app.targets, + ...(app.template === undefined ? {} : { template: app.template }), + }; +}; + +export const agentBundleBrowserRstest = async ( + options: AgentBundleBrowserRstestOptions = {}, +): Promise => { + const root = resolve(options.root ?? process.cwd()); + const manifest = await compileTestManifest({ + ...(options.configPath === undefined ? {} : { configPath: options.configPath }), + root, + }); + const configPath = manifest.configPath ?? resolve(root, options.configPath ?? 'agent-bundle.config.ts'); + const apps = Object.values(manifest.apps); + if (apps.length === 0) { + throw new Error( + `Browser-App test pool for ${JSON.stringify(configPath)} declares no MCP Apps to prove.`, + ); + } + const prebuilt = apps.find((app) => app.prebuilt === true); + if (prebuilt !== undefined) { + throw new Error( + `Browser-App test pool cannot compile prebuilt MCP App ${JSON.stringify(prebuilt.name)} from ${JSON.stringify(configPath)}.`, + ); + } + + const normalized = apps.map((app) => normalizedApp(app, configPath)); + const targets = Object.fromEntries( + apps.map((app) => [app.id, appTarget(app, manifest.targets, options.target, configPath)]), + ); + const outputRoot = resolve(root, '.agent-bundle', 'test', 'browser-app-build'); + await rm(outputRoot, { force: true, recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const compiled = await compileMcpApps(normalized, { + cwd: root, + outDir: outputRoot, + targets, + }); + if (compiled.length !== apps.length) { + throw new Error( + `Browser-App compiler emitted ${String(compiled.length)} of ${String(apps.length)} declared apps for ${JSON.stringify(configPath)}.`, + ); + } + const setup = await writeBrowserTestSetup(root, compiled); + const tsconfigPath = resolve(root, 'tsconfig.json'); + return { + browser: { + enabled: true, + headless: true, + provider: 'playwright', + providerOptions: { launch: { channel: 'chrome' } }, + viewport: { height: 900, width: 1440 }, + }, + include: [...(options.include ?? [browserAppInclude])], + setupFiles: [setup, ...(options.setupFiles ?? [])], + ...(existsSync(tsconfigPath) ? { source: { tsconfigPath } } : {}), + tools: { + rspack: { + resolve: { + extensionAlias: { '.js': ['.js', '.ts', '.tsx'], '.jsx': ['.jsx', '.tsx'] }, + }, + }, + swc: { jsc: { transform: { react: { runtime: 'automatic' } } } }, + }, + }; +}; diff --git a/packages/agent-bundle/src/rstest/index.ts b/packages/agent-bundle/src/rstest/index.ts index 28faaa37d..1a159426c 100644 --- a/packages/agent-bundle/src/rstest/index.ts +++ b/packages/agent-bundle/src/rstest/index.ts @@ -4,6 +4,12 @@ import { resolve } from 'node:path'; import { compileTestManifest } from '../test/manifest.ts'; import { writeRouteTestSetup } from './setup-module.ts'; +export { agentBundleBrowserRstest } from './browser.ts'; +export type { + AgentBundleBrowserRstestConfig, + AgentBundleBrowserRstestOptions, +} from './browser.ts'; + /** * The Node condition the React Server Components renderer requires. React * refuses to render Flight without it, and it is a process flag, so the diff --git a/packages/agent-bundle/src/test/browser-registry.ts b/packages/agent-bundle/src/test/browser-registry.ts new file mode 100644 index 000000000..78520db4f --- /dev/null +++ b/packages/agent-bundle/src/test/browser-registry.ts @@ -0,0 +1,20 @@ +export const AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY = 'agent-bundle/test-browser-app-registry'; +export const AGENT_BROWSER_TEST_REGISTRY_VERSION = 1; +export const BROWSER_APP_PROOF_LEVEL = 'browser-app' as const; +export const BROWSER_APP_PROOF_LEVEL_LABEL = 'browser-app (MCP App HTML compiled through the production Rsbuild profile, mounted in a real browser page over the product bridge; NOT host embedding, packed-artifact, or Workbench evidence)'; + +export interface CompiledBrowserTestApp { + readonly html: string; + readonly name: string; + /** Absolute staged HTML path that supplied `html`. */ + readonly output: string; + readonly proofLevel: 'browser-app'; + readonly resourceUri: string; + readonly serverIds: readonly string[]; + readonly target: string; +} + +export interface AgentBrowserTestRegistry { + readonly apps: Readonly>; + readonly version: number; +} diff --git a/packages/agent-bundle/tests/test-browser-rstest.test.ts b/packages/agent-bundle/tests/test-browser-rstest.test.ts new file mode 100644 index 000000000..ce092ade1 --- /dev/null +++ b/packages/agent-bundle/tests/test-browser-rstest.test.ts @@ -0,0 +1,62 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +import { agentBundleBrowserRstest } from '../src/rstest/browser.ts'; +import { + AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY, + AGENT_BROWSER_TEST_REGISTRY_VERSION, +} from '../src/test/browser-registry.ts'; + +const fixtureRoot = resolve(import.meta.dirname, '../fixtures/route-harness'); + +describe('agentBundleBrowserRstest', () => { + it('compiles every declared app once and writes the browser registry', async () => { + const config = await agentBundleBrowserRstest({ + root: fixtureRoot, + setupFiles: ['./tests/setup.ts'], + }); + + expect(config).toMatchObject({ + browser: { + enabled: true, + headless: true, + provider: 'playwright', + providerOptions: { launch: { channel: 'chrome' } }, + viewport: { height: 900, width: 1440 }, + }, + include: ['tests/browser-app/**/*.test.{ts,tsx}'], + setupFiles: [ + resolve(fixtureRoot, '.agent-bundle/test/browser-app-setup.mjs'), + './tests/setup.ts', + ], + tools: { + rspack: { + resolve: { + extensionAlias: { '.js': ['.js', '.ts', '.tsx'], '.jsx': ['.jsx', '.tsx'] }, + }, + }, + }, + }); + + const setup = await readFile(config.setupFiles[0]!, 'utf8'); + expect(setup).toContain(`"version":${String(AGENT_BROWSER_TEST_REGISTRY_VERSION)}`); + expect(setup).toContain(`Symbol.for(${JSON.stringify(AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY)})`); + expect(setup).toContain('"name":"panel"'); + expect(setup).toContain('"resourceUri":"ui://harness/panel"'); + expect(setup).toContain('"serverIds":["mcp:harness"]'); + expect(setup).toContain('"target":"claude"'); + expect(setup).toContain('"html":"'); + expect(setup).toContain('"proofLevel":"browser-app"'); + expect(setup).toContain('"output":'); + }); + + it('rejects a browser pool whose compiled manifest declares no apps', async () => { + const root = resolve(import.meta.dirname, '../../../fixtures/integration/skills-only'); + + await expect(agentBundleBrowserRstest({ root })).rejects.toThrow( + `Browser-App test pool for ${JSON.stringify(resolve(root, 'agent-bundle.config.ts'))} declares no MCP Apps to prove.`, + ); + }); +}); From 1501a6839b51c2ca86c623236f3978cd76880cd6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:02:57 +0000 Subject: [PATCH 3/7] feat(test): mount compiled apps in browsers Add a browser-safe public harness that drives sandboxed compiled HTML through the product bridge with bounded initialization, captured traffic, consent control, and proof-level provenance. --- packages/agent-bundle/package.json | 4 + packages/agent-bundle/rslib.config.ts | 1 + .../dev/mcp-apps/mcp-app-binding-service.ts | 4 +- .../src/dev/mcp-apps/mcp-app-bridge.ts | 22 +- .../src/dev/mcp-apps/mcp-app-consent.ts | 7 + .../src/dev/mcp-apps/mcp-app-sandbox.ts | 3 +- packages/agent-bundle/src/test/browser.ts | 455 ++++++++++++++++++ packages/agent-bundle/src/test/index.ts | 12 +- packages/agent-bundle/src/test/manifest.ts | 15 + .../tests/test-harness-manifest.test.ts | 38 +- 10 files changed, 539 insertions(+), 22 deletions(-) create mode 100644 packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts create mode 100644 packages/agent-bundle/src/test/browser.ts diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 34b6e40db..535184f8d 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -72,6 +72,10 @@ "./test": { "types": "./dist/test/index.d.ts", "import": "./dist/test.js" + }, + "./test/browser": { + "types": "./dist/test/browser.d.ts", + "import": "./dist/test/browser.js" } }, "dependencies": { diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 1e8780e8d..db0830d29 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ 'mcp-server-runtime': './src/mcp-server-runtime.ts', rstest: './src/rstest/index.ts', test: './src/test/index.ts', + 'test/browser': './src/test/browser.ts', }, }, }); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts index 17f1ef1f6..ecbced6c9 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts @@ -1,5 +1,3 @@ -import { randomUUID } from 'node:crypto'; - import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from '../mcp-app-profile-descriptors.ts'; export type McpAppJsonValue = @@ -218,7 +216,7 @@ export class McpAppBindingService { const toolDefinition = requireJson(tool.definition, 'MCP App leased tool definition') as McpAppToolDefinition; const binding = Object.freeze({ epochId: requireNonempty(identity.epochId, 'MCP App epoch id'), - id: randomUUID(), + id: crypto.randomUUID(), input, previewProfile, resourceUri, diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts index e8976bb9f..ab2975180 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts @@ -1,5 +1,3 @@ -import { Buffer } from 'node:buffer'; - import { validateMcpAppDownloadRequest, validateMcpAppExternalLink, @@ -10,13 +8,13 @@ import { type McpAppBinding, type McpAppJsonValue, } from './mcp-app-binding-service.ts'; +import { createMcpAppConsentActionDigest } from './mcp-app-consent.ts'; import type { McpAppConsentAuthority, McpAppConsentCapability, McpAppSandboxCsp, McpAppSandboxPermissions, } from './mcp-app-sandbox.ts'; -import { createMcpAppConsentActionDigest } from './mcp-app-sandbox.ts'; import { MCP_APP_PROTOCOL_VERSION } from '../mcp-app-profile-descriptors.ts'; export { MCP_APP_PROTOCOL_VERSION } from '../mcp-app-profile-descriptors.ts'; @@ -246,6 +244,8 @@ const hostStyleVariables = new Set([ const hasOwn = (value: object, key: string): boolean => Object.hasOwn(value, key); +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength; + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; @@ -616,10 +616,12 @@ const resourceMetadata = (value: unknown): { readonly csp?: McpAppSandboxCsp; re const htmlFromBlob = (blob: string): string | undefined => { if (blob.length === 0 || blob.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(blob)) return undefined; - const bytes = Buffer.from(blob, 'base64'); - if (bytes.toString('base64') !== blob) return undefined; try { - return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const decoded = atob(blob); + if (btoa(decoded) !== blob) return undefined; + return new TextDecoder('utf-8', { fatal: true }).decode( + Uint8Array.from(decoded, (character) => character.charCodeAt(0)), + ); } catch { return undefined; } @@ -636,7 +638,7 @@ export const parseMcpAppResource = (value: McpAppJsonValue, resourceUri: string) if (hasText === hasBlob) return undefined; const html = hasText ? content.text as string : htmlFromBlob(content.blob as string); const metadata = resourceMetadata(content._meta); - if (html === undefined || Buffer.byteLength(html, 'utf8') > MAX_APP_HTML_BYTES || metadata === undefined) return undefined; + if (html === undefined || utf8ByteLength(html) > MAX_APP_HTML_BYTES || metadata === undefined) return undefined; return Object.freeze({ ...metadata, html }); } return undefined; @@ -813,7 +815,7 @@ export const createMcpAppFailClosedSender = (options: Readonly maximumBytes) { + if (typeof serialized !== 'string' || utf8ByteLength(serialized) > maximumBytes) { block(); return Promise.resolve(false); } @@ -824,7 +826,7 @@ export const createMcpAppFailClosedSender = (options: Readonly= maximumMessages || queuedBytes + byteLength > maximumBytes) { block(); return Promise.resolve(false); @@ -899,7 +901,7 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr const pendingConsentActions = new Map(); const isClosed = (): boolean => lifecycle === 'closing' || lifecycle === 'closed'; - const hostMessageByteLength = (message: McpAppBridgeMessage): number => Buffer.byteLength(JSON.stringify(message), 'utf8'); + const hostMessageByteLength = (message: McpAppBridgeMessage): number => utf8ByteLength(JSON.stringify(message)); const enqueueHostMessage = (message: McpAppBridgeMessage): boolean => { const byteLength = hostMessageByteLength(message); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts new file mode 100644 index 000000000..768027d42 --- /dev/null +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts @@ -0,0 +1,7 @@ +import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; +import type { McpAppConsentCapability } from './mcp-app-sandbox.ts'; + +export const createMcpAppConsentActionDigest = ( + capability: McpAppConsentCapability, + details: McpAppJsonValue, +): string => `${capability}:${JSON.stringify(details)}`; diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts index e827135d0..8018bb86c 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts @@ -3,6 +3,7 @@ import { createServer, type Server } from 'node:http'; import { isIP, type Socket } from 'node:net'; import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; +import { createMcpAppConsentActionDigest } from './mcp-app-consent.ts'; const JSON_RPC_VERSION = '2.0'; const SANDBOX_NOTIFICATION_PREFIX = 'ui/notifications/sandbox-'; @@ -312,7 +313,7 @@ const consentScope = (capability: McpAppConsentCapability): 'action' | 'document const consentSummary = (capability: McpAppConsentCapability): string => `Allow MCP App ${capability.replaceAll('-', ' ')}?`; -export const createMcpAppConsentActionDigest = (capability: McpAppConsentCapability, details: McpAppJsonValue): string => `${capability}:${JSON.stringify(details)}`; +export { createMcpAppConsentActionDigest } from './mcp-app-consent.ts'; const consentSensitiveName = /(?:api[_-]?key|authorization|bearer|cookie|credential|pass(?:word)?|private[_-]?key|secret|token)/iu; diff --git a/packages/agent-bundle/src/test/browser.ts b/packages/agent-bundle/src/test/browser.ts new file mode 100644 index 000000000..f61de0b88 --- /dev/null +++ b/packages/agent-bundle/src/test/browser.ts @@ -0,0 +1,455 @@ +import { + createMcpAppBridge, + type McpAppBridge, + type McpAppBridgeBindingOperations, + type McpAppBridgeHost, + type McpAppBridgeLogEvent, + type McpAppBridgeMessage, + type McpAppBridgeMessageEvent, + type McpAppBridgeModelContext, + type McpAppBridgeSize, + type McpAppBridgeJsonRecord, + type McpAppValidatedDownload, +} from '../dev/mcp-apps/mcp-app-bridge.ts'; +import type { + McpAppBinding, + McpAppJsonValue, + McpAppToolDefinition, +} from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; +import type { + McpAppConsentAuthority, + McpAppConsentCapability, + McpAppConsentChallenge, + McpAppConsentGrant, + McpAppConsentResolution, +} from '../dev/mcp-apps/mcp-app-sandbox.ts'; +import { + AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY, + AGENT_BROWSER_TEST_REGISTRY_VERSION, + BROWSER_APP_PROOF_LEVEL, + BROWSER_APP_PROOF_LEVEL_LABEL, + type AgentBrowserTestRegistry, + type CompiledBrowserTestApp, +} from './browser-registry.ts'; + +export interface BrowserAppProvenance { + readonly name: string; + readonly output: string; + readonly proofLevel: typeof BROWSER_APP_PROOF_LEVEL; + readonly resourceUri: string; + readonly target: string; +} + +export interface BrowserAppTraffic { + readonly direction: 'app-to-host' | 'host-to-app'; + readonly message: McpAppBridgeMessage; +} + +export interface BrowserAppHostTraffic { + readonly downloads: readonly McpAppValidatedDownload[]; + readonly logs: readonly McpAppBridgeLogEvent[]; + readonly messages: readonly McpAppBridgeMessageEvent[]; + readonly modelContexts: readonly McpAppBridgeModelContext[]; + readonly openLinks: readonly string[]; + readonly sizes: readonly McpAppBridgeSize[]; +} + +export type BrowserAppScriptedConsent = + | 'manual' + | 'approve' + | 'deny' + | ((challenge: McpAppConsentChallenge) => boolean | undefined); + +export interface MountBrowserAppOptions { + readonly consentAuthority?: McpAppConsentAuthority; + readonly container?: HTMLElement; + readonly host?: Partial; + readonly operations: McpAppBridgeBindingOperations; + readonly profile?: McpAppProfileId; + readonly scriptedConsent?: BrowserAppScriptedConsent; + readonly timeoutMs?: number; + readonly toolDefinition?: McpAppToolDefinition; + readonly toolInput?: McpAppBridgeJsonRecord; + readonly toolName?: string; + readonly toolResult: McpAppJsonValue; +} + +export interface MountedBrowserApp { + readonly bridge: McpAppBridge; + decideConsent(challengeId: string, approved: boolean): Promise; + readonly document: Document; + readonly hostTraffic: BrowserAppHostTraffic; + readonly iframe: HTMLIFrameElement; + readonly pendingConsentChallenges: readonly McpAppConsentChallenge[]; + readonly provenance: BrowserAppProvenance; + publishHostContextChanged(context: McpAppBridgeJsonRecord): boolean; + publishToolCancelled(reason?: string): boolean; + publishToolInput(input?: McpAppBridgeJsonRecord): boolean; + publishToolResult(result: McpAppJsonValue): boolean; + readonly traffic: readonly BrowserAppTraffic[]; + dispose(): Promise; +} + +export class BrowserAppTestError extends Error { + readonly provenance: BrowserAppProvenance; + + constructor(message: string, provenance: BrowserAppProvenance, details: readonly string[] = []) { + super([ + message, + ` proof level: ${BROWSER_APP_PROOF_LEVEL_LABEL}`, + ` app: ${provenance.name} (${provenance.resourceUri}, target ${provenance.target})`, + ` output: ${provenance.output}`, + ...details.map((detail) => ` ${detail}`), + ].join('\n')); + this.name = 'BrowserAppTestError'; + this.provenance = provenance; + } +} + +const registrySymbol = Symbol.for(AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY); +const realm = globalThis as typeof globalThis & { [registrySymbol]?: AgentBrowserTestRegistry }; +let nextBindingId = 1; + +const unavailableProvenance = (name: string): BrowserAppProvenance => ({ + name, + output: 'generated browser registry unavailable', + proofLevel: BROWSER_APP_PROOF_LEVEL, + resourceUri: 'unavailable', + target: 'unavailable', +}); + +const registeredApp = (name: string): CompiledBrowserTestApp => { + const registry = realm[registrySymbol]; + if (registry === undefined) { + throw new BrowserAppTestError( + 'No compiled browser App registry is registered in this browser worker.', + unavailableProvenance(name), + ['recovery: build this pool with agentBundleBrowserRstest() from agent-bundle/rstest'], + ); + } + if (registry.version !== AGENT_BROWSER_TEST_REGISTRY_VERSION) { + throw new BrowserAppTestError( + `Incompatible browser App registry version ${String(registry.version)}; expected ${String(AGENT_BROWSER_TEST_REGISTRY_VERSION)}.`, + unavailableProvenance(name), + ); + } + const app = registry.apps[name]; + if (app === undefined) { + throw new BrowserAppTestError( + `Compiled browser App ${JSON.stringify(name)} was not found.`, + unavailableProvenance(name), + [`available: ${Object.keys(registry.apps).sort().join(', ') || 'none'}`], + ); + } + return app; +}; + +const provenanceOf = (app: CompiledBrowserTestApp): BrowserAppProvenance => Object.freeze({ + name: app.name, + output: app.output, + proofLevel: BROWSER_APP_PROOF_LEVEL, + resourceUri: app.resourceUri, + target: app.target, +}); + +const isBridgeMessage = (value: unknown): value is McpAppBridgeMessage => + typeof value === 'object' && value !== null && (value as { readonly jsonrpc?: unknown }).jsonrpc === '2.0'; + +const createTestConsentAuthority = (): McpAppConsentAuthority => { + const challenges = new Map(); + const grants = new Map(); + let nextId = 1; + const resolve = (challengeId: string, approved: boolean): McpAppConsentResolution => { + const challenge = challenges.get(challengeId); + if (challenge === undefined) return Object.freeze({ status: 'unknown' }); + challenges.delete(challengeId); + if (!approved) return Object.freeze({ status: 'denied' }); + const grant = grants.get(challengeId); + return grant === undefined + ? Object.freeze({ status: 'unknown' }) + : Object.freeze({ grant, status: 'approved' }); + }; + return Object.freeze({ + challenge(options: Readonly<{ + readonly actionDigest: string; + readonly bindingId: string; + readonly capability: McpAppConsentCapability; + readonly details: McpAppJsonValue; + readonly profile: string; + }>) { + const id = `browser-consent-${String(nextId++)}`; + const challenge: McpAppConsentChallenge = Object.freeze({ + expiresAt: Number.MAX_SAFE_INTEGER, + id, + request: Object.freeze({ + actionFingerprint: `browser-action-${String(nextId)}`, + capability: options.capability, + details: options.details, + scope: 'action', + summary: `Allow MCP App ${options.capability.replaceAll('-', ' ')}?`, + }), + }); + grants.set(id, Object.freeze({ + actionDigest: options.actionDigest, + authorizationId: `browser-grant-${String(nextId)}`, + bindingId: options.bindingId, + capability: options.capability, + challengeId: id, + profile: options.profile, + scope: 'action', + })); + challenges.set(id, challenge); + return challenge; + }, + consume(options: Parameters[0]) { + const grant = [...grants.values()].find( + (candidate) => candidate.authorizationId === options.authorizationId, + ); + if (grant === undefined + || grant.actionDigest !== options.actionDigest + || grant.bindingId !== options.bindingId + || grant.capability !== options.capability + || grant.profile !== options.profile) return false; + grants.delete(grant.challengeId); + return true; + }, + documentGrants: () => Object.freeze([]), + grant(challengeId: string, approved: boolean) { + const resolution = resolve(challengeId, approved); + return resolution.status === 'approved' ? resolution.grant : undefined; + }, + inspect: (challengeId: string) => challenges.get(challengeId), + pending: () => Object.freeze([...challenges.values()]), + resolve, + }); +}; + +const selectedProfile = (app: CompiledBrowserTestApp, requested: McpAppProfileId | undefined): McpAppProfileId => { + if (requested !== undefined) return requested; + return app.target === 'claude' || app.target === 'portable' ? app.target : 'portable'; +}; + +const bindingFor = ( + app: CompiledBrowserTestApp, + options: MountBrowserAppOptions, +): McpAppBinding => { + const toolName = options.toolName ?? `show-${app.name}`; + return Object.freeze({ + epochId: `browser-app:${app.name}`, + id: `browser-app-binding-${String(nextBindingId++)}`, + input: options.toolInput ?? {}, + previewProfile: selectedProfile(app, options.profile), + resourceUri: app.resourceUri, + result: options.toolResult, + serverName: app.serverIds.join(','), + sessionId: `browser-app-session:${app.name}`, + target: app.target, + toolDefinition: options.toolDefinition ?? Object.freeze({ + _meta: { ui: { resourceUri: app.resourceUri } }, + inputSchema: { type: 'object' }, + name: toolName, + }), + toolName, + }); +}; + +export const mountBrowserApp = async ( + name: string, + options: MountBrowserAppOptions, +): Promise => { + const app = registeredApp(name); + const provenance = provenanceOf(app); + const authority = options.consentAuthority ?? createTestConsentAuthority(); + const traffic: BrowserAppTraffic[] = []; + const downloads: McpAppValidatedDownload[] = []; + const logs: McpAppBridgeLogEvent[] = []; + const messages: McpAppBridgeMessageEvent[] = []; + const modelContexts: McpAppBridgeModelContext[] = []; + const openLinks: string[] = []; + const sizes: McpAppBridgeSize[] = []; + const userHost = options.host; + const host: McpAppBridgeHost = { + capabilities: userHost?.capabilities ?? { + downloadFile: {}, + logging: {}, + openLinks: {}, + serverResources: {}, + serverTools: {}, + }, + context: userHost?.context ?? { + availableDisplayModes: ['inline'], + displayMode: 'inline', + platform: 'desktop', + }, + info: userHost?.info ?? { name: 'agent-bundle-browser-test', version: '1.0.0' }, + ...(userHost?.onDisplayMode === undefined ? {} : { onDisplayMode: userHost.onDisplayMode }), + onDownload: async (download) => { + downloads.push(download); + await userHost?.onDownload?.(download); + }, + onLog: async (event) => { + logs.push(event); + await userHost?.onLog?.(event); + }, + onMessage: async (event) => { + messages.push(event); + return userHost?.onMessage?.(event); + }, + onModelContext: async (context) => { + modelContexts.push(context); + await userHost?.onModelContext?.(context); + }, + onOpenLink: async (url) => { + openLinks.push(url); + await userHost?.onOpenLink?.(url); + }, + onSizeChanged: async (size) => { + sizes.push(size); + await userHost?.onSizeChanged?.(size); + }, + }; + + const iframe = document.createElement('iframe'); + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin'); + iframe.referrerPolicy = 'no-referrer'; + iframe.srcdoc = app.html; + + let disposed = false; + let settleHandshake!: () => void; + let rejectHandshake!: (error: unknown) => void; + const handshake = new Promise((resolve, reject) => { + settleHandshake = resolve; + rejectHandshake = reject; + }); + const settleScriptedConsent = async (): Promise => { + const script = options.scriptedConsent ?? 'manual'; + if (script === 'manual') return; + for (const challenge of authority.pending()) { + const approved = typeof script === 'function' ? script(challenge) : script === 'approve'; + if (approved !== undefined) await bridge.decideConsent(challenge.id, approved); + } + }; + const onMessage = (event: MessageEvent): void => { + if (event.source !== iframe.contentWindow || !isBridgeMessage(event.data)) return; + traffic.push(Object.freeze({ direction: 'app-to-host', message: event.data })); + void bridge.receive(event.data) + .then(settleScriptedConsent) + .then(() => { + if (bridge.lifecycle === 'initialized') settleHandshake(); + }, rejectHandshake); + }; + const bridge = (() => { + try { + return createMcpAppBridge({ + binding: bindingFor(app, options), + consentAuthority: authority, + host, + operations: options.operations, + profile: selectedProfile(app, options.profile), + send: (message) => { + const target = iframe.contentWindow; + if (target === null) return false; + traffic.push(Object.freeze({ direction: 'host-to-app', message })); + target.postMessage(message, '*'); + return true; + }, + }); + } catch (error) { + throw new BrowserAppTestError('MCP App bridge could not be created.', provenance, [ + `cause: ${error instanceof Error ? error.message : String(error)}`, + ]); + } + })(); + window.addEventListener('message', onMessage); + if (!bridge.publishToolResult(options.toolResult)) { + window.removeEventListener('message', onMessage); + throw new BrowserAppTestError('The initial MCP App tool result was rejected.', provenance); + } + + (options.container ?? document.body).append(iframe); + const timeoutMs = options.timeoutMs ?? 5_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) { + window.removeEventListener('message', onMessage); + await bridge.forceClose().catch(() => undefined); + iframe.remove(); + throw new BrowserAppTestError( + 'MCP App initialize timeout must be an integer from 1 to 30000 ms.', + provenance, + ); + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + handshake, + new Promise((_, reject) => { + timeout = setTimeout(() => { + const last = traffic.at(-1)?.message; + reject(new BrowserAppTestError( + `MCP App initialize handshake timed out after ${String(timeoutMs)} ms.`, + provenance, + [`last message: ${last === undefined ? 'none' : JSON.stringify(last)}`], + )); + }, timeoutMs); + }), + ]); + } catch (error) { + window.removeEventListener('message', onMessage); + await bridge.forceClose().catch(() => undefined); + iframe.remove(); + if (error instanceof BrowserAppTestError) throw error; + throw new BrowserAppTestError('MCP App initialize handshake failed.', provenance, [ + `cause: ${error instanceof Error ? error.message : String(error)}`, + `last message: ${traffic.length === 0 ? 'none' : JSON.stringify(traffic.at(-1)?.message)}`, + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } + + const appDocument = iframe.contentDocument; + if (appDocument === null) { + window.removeEventListener('message', onMessage); + await bridge.forceClose().catch(() => undefined); + iframe.remove(); + throw new BrowserAppTestError('Mounted MCP App document is not accessible.', provenance); + } + + const hostTraffic: BrowserAppHostTraffic = { + downloads, + logs, + messages, + modelContexts, + openLinks, + sizes, + }; + return Object.freeze({ + bridge, + decideConsent: (challengeId: string, approved: boolean) => bridge.decideConsent(challengeId, approved), + dispose: async () => { + if (disposed) return; + disposed = true; + window.removeEventListener('message', onMessage); + try { + await bridge.forceClose(); + } catch (error) { + throw new BrowserAppTestError('MCP App bridge disposal failed.', provenance, [ + `cause: ${error instanceof Error ? error.message : String(error)}`, + ]); + } finally { + iframe.remove(); + } + }, + document: appDocument, + hostTraffic, + iframe, + get pendingConsentChallenges(): readonly McpAppConsentChallenge[] { + return authority.pending(); + }, + provenance, + publishHostContextChanged: (context: McpAppBridgeJsonRecord) => bridge.publishHostContextChanged(context), + publishToolCancelled: (reason?: string) => bridge.publishToolCancelled(reason), + publishToolInput: (input?: McpAppBridgeJsonRecord) => bridge.publishToolInput(input), + publishToolResult: (result: McpAppJsonValue) => bridge.publishToolResult(result), + traffic, + }); +}; diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 3a35435e9..31747b212 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -1,9 +1,9 @@ /** * `agent-bundle/test` — the consumer test harness helpers. * - * Four proof levels ship here, and they are deliberately separate. Each - * helper names the level it supplies, stamps it into its provenance, and - * prints it in every failure: + * Four Node proof levels ship here, and the browser-safe fifth level ships + * from `agent-bundle/test/browser`. Each helper names the level it supplies, + * stamps it into its provenance, and prints it in every failure: * * | level | helper | what it proves | * | --- | --- | --- | @@ -11,10 +11,10 @@ * | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface` | the real generated MCP server's protocol contract, over the SDK's in-memory transport | * | `cli-dispatch` | `invokeCli`, `cliJson` | a compiled CLI command dispatched through the routed CLI's own shell, in this process | * | `packed-stdio` | `openPackedMcpServer` | a built artifact's generated entry running as a real process over stdio | + * | `browser-app` | `mountBrowserApp` (`agent-bundle/test/browser`) | production-compiled MCP App HTML mounted over the product bridge in a real browser page | * - * A pass at one level is never a receipt for another. Browser-App surfaces - * and deleted-source artifact proofs are later stages; nothing here stands in - * for them. + * A pass at one level is never a receipt for another. Deleted-source artifact + * proof is a later stage; nothing here stands in for it. */ export { BROWSER_APP_PROOF_LEVEL, diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index 8ae853149..caa4529fb 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -1,6 +1,7 @@ import { relative, resolve, sep } from 'node:path'; import type { Diagnostic } from '../core/diagnostics.ts'; +import { stableJson } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; import type { NormalizedMcpApp } from '../core/types.ts'; import type { @@ -164,9 +165,22 @@ const appDescriptors = ( projectRoot: string, ): Readonly> => { const descriptors: Record = {}; + const identities = new Map(); for (const app of apps) { + const identity = stableJson({ + ...(app._meta === undefined ? {} : { _meta: app._meta }), + resourceUri: app.resourceUri, + source: app.source, + ...(app.template === undefined ? {} : { template: app.template }), + }); const existing = descriptors[app.name]; if (existing !== undefined) { + if (identities.get(app.name) !== identity) { + throw new Error( + `Duplicate compiled MCP App destination ${JSON.stringify(`mcp-apps/${app.name}.html`)}; ` + + 'servers may share an app name only with an identical declaration.', + ); + } descriptors[app.name] = { ...existing, serverIds: [...new Set([...existing.serverIds, app.serverId])].sort((left, right) => left.localeCompare(right)), @@ -174,6 +188,7 @@ const appDescriptors = ( }; continue; } + identities.set(app.name, identity); descriptors[app.name] = { ...(app._meta === undefined ? {} : { _meta: app._meta }), id: app.id, diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index 7330d5c61..4c0bce7b8 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -131,6 +131,16 @@ describe('the compiled test manifest', () => { source: resolve(fixtureRoot, 'views/panel.ts'), targets: ['claude'], template: resolve(fixtureRoot, 'views/panel.html'), + }, { + id: 'mcp-app:mirror:panel', + name: 'panel', + provenance: { kind: 'config', sourcePath: resolve(fixtureRoot, 'agent-bundle.config.ts') }, + resourceUri: 'ui://harness/panel.html', + serverId: 'mcp:mirror', + serverName: 'mirror', + source: resolve(fixtureRoot, 'views/panel.ts'), + targets: ['claude'], + template: resolve(fixtureRoot, 'views/panel.html'), }], graph, projectRoot: fixtureRoot, @@ -144,7 +154,7 @@ describe('the compiled test manifest', () => { name: 'panel', relativePath: 'views/panel.ts', resourceUri: 'ui://harness/panel.html', - serverIds: ['mcp:harness'], + serverIds: ['mcp:harness', 'mcp:mirror'], source: resolve(fixtureRoot, 'views/panel.ts'), targets: ['claude'], template: resolve(fixtureRoot, 'views/panel.html'), @@ -154,6 +164,26 @@ describe('the compiled test manifest', () => { expect(Object.isFrozen(projected.routes)).toBe(true); }); + it('rejects a shared app name whose compile-relevant declaration differs', async () => { + const graph = await compileRouteGraph(fixtureRoot, { targets: ['claude'] } as never); + const app = { + id: 'mcp-app:harness:panel', + name: 'panel', + provenance: { kind: 'config' as const, sourcePath: resolve(fixtureRoot, 'agent-bundle.config.ts') }, + resourceUri: 'ui://harness/panel.html', + serverId: 'mcp:harness', + serverName: 'harness', + source: resolve(fixtureRoot, 'views/panel.ts'), + targets: ['claude'], + }; + + expect(() => testManifestFromRouteGraph({ + apps: [app, { ...app, id: 'mcp-app:mirror:panel', serverId: 'mcp:mirror', source: resolve(fixtureRoot, 'views/other.ts') }], + graph, + projectRoot: fixtureRoot, + })).toThrow('servers may share an app name only with an identical declaration'); + }); + }); describe('the generated route registry', () => { @@ -456,7 +486,7 @@ describe('the manifest a route-free project compiles', () => { describe('the harness package boundary', () => { const packageRoot = resolve(import.meta.dirname, '..'); - it('publishes both harness subpaths', async () => { + it('publishes the Node and browser harness subpaths', async () => { const declared = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')) as { dependencies: Readonly>; peerDependencies: Readonly>; @@ -466,6 +496,10 @@ describe('the harness package boundary', () => { expect(declared.exports['./rstest']).toEqual({ import: './dist/rstest.js', types: './dist/rstest/index.d.ts' }); expect(declared.exports['./test']).toEqual({ import: './dist/test.js', types: './dist/test/index.d.ts' }); + expect(declared.exports['./test/browser']).toEqual({ + import: './dist/test/browser.js', + types: './dist/test/browser.d.ts', + }); // Declaring the three as optional peers is also what keeps them external in // the published bundle: vendoring the Flight server would ship a second // React server copy whose RSC manifest is not the consumer project's. From 346524f507b01f4444d780b3b8bdc593bffd4cf2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:03:10 +0000 Subject: [PATCH 4/7] test(example): prove MCP App browser behavior Adopt the browser harness in the public MCP App example and cover rendering, resource binding, consent approval and denial, accessibility, and fail-closed operations. --- examples/mcp-app/package.json | 5 + examples/mcp-app/rstest.browser-app.config.ts | 4 + .../browser-app/status-panel.browser.test.ts | 134 ++++++++++++++++++ examples/mcp-app/views/status-panel.html | 3 + examples/mcp-app/views/status-panel.ts | 25 ++++ pnpm-lock.yaml | 12 ++ 6 files changed, 183 insertions(+) create mode 100644 examples/mcp-app/rstest.browser-app.config.ts create mode 100644 examples/mcp-app/tests/browser-app/status-panel.browser.test.ts diff --git a/examples/mcp-app/package.json b/examples/mcp-app/package.json index 49508a1c7..c730ef9c8 100644 --- a/examples/mcp-app/package.json +++ b/examples/mcp-app/package.json @@ -6,12 +6,17 @@ "build": "agent-bundle build", "check": "pnpm validate && pnpm build", "dev": "agent-bundle dev", + "test:browser-app": "rstest --config rstest.browser-app.config.ts", "validate": "agent-bundle validate" }, "devDependencies": { "@modelcontextprotocol/ext-apps": "1.7.5", "@modelcontextprotocol/server": "2.0.0", + "@rstest/browser": "0.11.10", + "@rstest/core": "0.11.10", + "@rstest/playwright": "0.11.10", "agent-bundle": "workspace:*", + "playwright": "1.62.1", "zod": "4.4.3" } } diff --git a/examples/mcp-app/rstest.browser-app.config.ts b/examples/mcp-app/rstest.browser-app.config.ts new file mode 100644 index 000000000..6023c1bf8 --- /dev/null +++ b/examples/mcp-app/rstest.browser-app.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from '@rstest/core'; +import { agentBundleBrowserRstest } from 'agent-bundle/rstest'; + +export default defineConfig(await agentBundleBrowserRstest()); diff --git a/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts new file mode 100644 index 000000000..909cd1513 --- /dev/null +++ b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts @@ -0,0 +1,134 @@ +import { afterEach, expect, it } from '@rstest/core'; + +import { mountBrowserApp, type MountedBrowserApp } from 'agent-bundle/test/browser'; + +const statusResult = Object.freeze({ + content: Object.freeze([Object.freeze({ + text: 'Payment latency is above the release threshold.', + type: 'text', + })]), + structuredContent: Object.freeze({ + checks: Object.freeze([ + Object.freeze({ label: 'Availability', status: 'passing' }), + Object.freeze({ label: 'P95 latency', status: 'failing' }), + ]), + service: 'payments-api', + status: 'degraded', + summary: 'Payment latency is above the release threshold.', + }), +}); + +const mounted: MountedBrowserApp[] = []; + +afterEach(async () => { + await Promise.all(mounted.splice(0).map((app) => app.dispose())); +}); + +const waitFor = async (predicate: () => boolean, timeoutMs = 2_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for the status panel.'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; + +const operations = (options: { + readonly callTool?: () => Promise; + readonly calls?: string[]; + readonly reads?: string[]; +} = {}) => ({ + callTool: async (_bindingId: string, request: { readonly name: string }) => { + options.calls?.push(request.name); + return options.callTool?.() ?? statusResult; + }, + closeBinding: async () => true, + readResource: async (_bindingId: string, request: { readonly uri: string }) => { + options.reads?.push(request.uri); + return { + contents: [{ + mimeType: 'text/plain', + text: 'Only passing checks permit release.', + uri: request.uri, + }], + }; + }, +}); + +const mountStatus = async (overrides: Partial[1]> = {}) => { + const app = await mountBrowserApp('status', { + operations: operations(), + toolInput: { service: 'payments-api' }, + toolResult: statusResult, + ...overrides, + }); + mounted.push(app); + return app; +}; + +it('mounts the compiled panel, initializes the bridge, and renders the published result accessibly', async () => { + const app = await mountStatus(); + await waitFor(() => app.document.querySelector('#status')?.textContent === 'degraded'); + + expect(app.bridge.lifecycle).toBe('initialized'); + expect(app.document.querySelector('main')).not.toBeNull(); + expect(app.document.querySelector('h1')?.textContent).toBe('payments-api'); + expect(app.document.querySelector('[aria-label="Service checks"]')).not.toBeNull(); + expect(app.document.querySelectorAll('#checks li')).toHaveLength(2); + expect(app.provenance).toMatchObject({ proofLevel: 'browser-app', target: 'portable' }); + expect(app.traffic.some(({ message }) => message.method === 'ui/notifications/tool-result')).toBe(true); +}); + +it('round-trips a resource read from the real App through binding operations', async () => { + const reads: string[] = []; + const app = await mountStatus({ operations: operations({ reads }) }); + + app.document.querySelector('#read-policy')!.click(); + await waitFor(() => app.document.querySelector('#bridge-outcome')?.textContent?.includes('passing checks') === true); + + expect(reads).toEqual(['ui://mcp-app-example/readiness-policy']); + expect(app.traffic.some(({ message }) => message.method === 'resources/read')).toBe(true); +}); + +it('holds a tool call for consent, resumes approval once, and denies without calling the binding', async () => { + const approvedCalls: string[] = []; + const approved = await mountStatus({ operations: operations({ calls: approvedCalls }) }); + approved.document.querySelector('#refresh-status')!.click(); + await waitFor(() => approved.pendingConsentChallenges.length === 1); + + const challenge = approved.pendingConsentChallenges[0]!; + expect(challenge.request.capability).toBe('call-tool'); + expect(approvedCalls).toEqual([]); + await expect(approved.decideConsent(challenge.id, true)).resolves.toBe(true); + await waitFor(() => approved.document.querySelector('#bridge-outcome')?.textContent === 'Status refreshed.'); + expect(approvedCalls).toEqual(['refresh-status']); + + const deniedCalls: string[] = []; + const denied = await mountStatus({ operations: operations({ calls: deniedCalls }) }); + denied.document.querySelector('#refresh-status')!.click(); + await waitFor(() => denied.pendingConsentChallenges.length === 1); + await expect(denied.decideConsent(denied.pendingConsentChallenges[0]!.id, false)).resolves.toBe(true); + await waitFor(() => denied.document.querySelector('#bridge-outcome')?.textContent === 'Refresh unavailable.'); + + expect(deniedCalls).toEqual([]); + expect(denied.traffic.some(({ message }) => message.error?.code === -32001)).toBe(true); +}); + +it('fails closed when a consented binding operation is unavailable', async () => { + const calls: string[] = []; + const app = await mountStatus({ + operations: operations({ + calls, + callTool: async () => { + throw new Error('unavailable'); + }, + }), + }); + app.document.querySelector('#refresh-status')!.click(); + await waitFor(() => app.pendingConsentChallenges.length === 1); + await app.decideConsent(app.pendingConsentChallenges[0]!.id, true); + await waitFor(() => app.document.querySelector('#bridge-outcome')?.textContent === 'Refresh unavailable.'); + + expect(calls).toEqual(['refresh-status']); + expect(app.traffic.some(({ message }) => message.error?.code === -32000)).toBe(true); + expect(app.document.querySelector('#bridge-outcome')?.textContent).not.toBe('Status refreshed.'); +}); diff --git a/examples/mcp-app/views/status-panel.html b/examples/mcp-app/views/status-panel.html index e63348ef8..c500bdac4 100644 --- a/examples/mcp-app/views/status-panel.html +++ b/examples/mcp-app/views/status-panel.html @@ -55,7 +55,10 @@

No service selected

Invoke the readiness tool to inspect a service.

    + + +

    diff --git a/examples/mcp-app/views/status-panel.ts b/examples/mcp-app/views/status-panel.ts index 7d736b42a..34a9fd0c4 100644 --- a/examples/mcp-app/views/status-panel.ts +++ b/examples/mcp-app/views/status-panel.ts @@ -6,6 +6,7 @@ const statusIndicator = document.querySelector('#status-indicator') const status = document.querySelector('#status')!; const summary = document.querySelector('#summary')!; const checks = document.querySelector('#checks')!; +const bridgeOutcome = document.querySelector('#bridge-outcome')!; type StatusState = 'checking' | 'healthy' | 'degraded' | 'unknown'; @@ -71,4 +72,28 @@ document.querySelector('#toggle-details')!.addEventListener('click', () => { document.querySelector('#details')!.toggleAttribute('hidden'); }); +document.querySelector('#read-policy')!.addEventListener('click', async () => { + try { + const result = await app.readServerResource({ uri: 'ui://mcp-app-example/readiness-policy' }); + const content = result.contents[0]; + bridgeOutcome.textContent = content !== undefined && 'text' in content + ? content.text + : 'Readiness policy unavailable.'; + } catch { + bridgeOutcome.textContent = 'Readiness policy unavailable.'; + } +}); + +document.querySelector('#refresh-status')!.addEventListener('click', async () => { + try { + const result = await app.callServerTool({ + arguments: { service: serviceHeading.textContent ?? 'service' }, + name: 'refresh-status', + }); + bridgeOutcome.textContent = result.isError === true ? 'Refresh unavailable.' : 'Status refreshed.'; + } catch { + bridgeOutcome.textContent = 'Refresh unavailable.'; + } +}); + await app.connect(new PostMessageTransport(window.parent, window.parent)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f935c6d..fe5d89cd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,9 +117,21 @@ importers: '@modelcontextprotocol/server': specifier: 2.0.0 version: 2.0.0 + '@rstest/browser': + specifier: 0.11.10 + version: 0.11.10(@rstest/core@0.11.10)(playwright@1.62.1) + '@rstest/core': + specifier: 0.11.10 + version: 0.11.10 + '@rstest/playwright': + specifier: 0.11.10 + version: 0.11.10(@rstest/core@0.11.10)(playwright@1.62.1) agent-bundle: specifier: workspace:* version: link:../../packages/agent-bundle + playwright: + specifier: 1.62.1 + version: 1.62.1 zod: specifier: 4.4.3 version: 4.4.3 From dc3ec3bd8009ef226638d94b4e6cc648d753a742 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:06:21 +0000 Subject: [PATCH 5/7] test(rstest): bound browser compiler coverage Give the real app-compilation test enough time under the full parallel suite and remove the redundant consent re-export import flagged by lint. --- packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts | 1 - packages/agent-bundle/tests/test-browser-rstest.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts index 8018bb86c..cc02d93dd 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts @@ -3,7 +3,6 @@ import { createServer, type Server } from 'node:http'; import { isIP, type Socket } from 'node:net'; import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; -import { createMcpAppConsentActionDigest } from './mcp-app-consent.ts'; const JSON_RPC_VERSION = '2.0'; const SANDBOX_NOTIFICATION_PREFIX = 'ui/notifications/sandbox-'; diff --git a/packages/agent-bundle/tests/test-browser-rstest.test.ts b/packages/agent-bundle/tests/test-browser-rstest.test.ts index ce092ade1..ed0e05b02 100644 --- a/packages/agent-bundle/tests/test-browser-rstest.test.ts +++ b/packages/agent-bundle/tests/test-browser-rstest.test.ts @@ -12,7 +12,7 @@ import { const fixtureRoot = resolve(import.meta.dirname, '../fixtures/route-harness'); describe('agentBundleBrowserRstest', () => { - it('compiles every declared app once and writes the browser registry', async () => { + it('compiles every declared app once and writes the browser registry', { timeout: 30_000 }, async () => { const config = await agentBundleBrowserRstest({ root: fixtureRoot, setupFiles: ['./tests/setup.ts'], From ae2ad7a423a1fb0f4a9317f0a8ec9ddecc577c08 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:19:07 +0000 Subject: [PATCH 6/7] test: pin the browser registry label to the manifest label --- packages/agent-bundle/tests/test-harness-manifest.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index 4c0bce7b8..2eaaed95a 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -4,6 +4,7 @@ import { resolve } from 'node:path'; import { describe, expect, it } from '@rstest/core'; import { routeTestSetupSource } from '../src/rstest/setup-module.ts'; +import { BROWSER_APP_PROOF_LEVEL_LABEL } from '../src/test/browser-registry.ts'; import { AgentTestError } from '../src/test/errors.ts'; import { invokeCli } from '../src/test/cli.ts'; import { compileTestManifest, proofLevelLabel, testManifestFromRouteGraph } from '../src/test/manifest.ts'; @@ -43,6 +44,9 @@ describe('the compiled test manifest', () => { expect(proofLevelLabel('browser-app')).toBe( 'browser-app (MCP App HTML compiled through the production Rsbuild profile, mounted in a real browser page over the product bridge; NOT host embedding, packed-artifact, or Workbench evidence)', ); + // The browser-safe registry module cannot import this Node-side label, so + // it carries its own copy; the copies must never drift apart. + expect(BROWSER_APP_PROOF_LEVEL_LABEL).toBe(proofLevelLabel('browser-app')); }); it('names every conventional route the compiler discovered, with its extracted config', () => { From 590b1781b5e3cd8ef268cbd6535144061fa39d3c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:24:36 +0000 Subject: [PATCH 7/7] chore: changeset for the browser-app harness level --- .changeset/browser-app-harness.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/browser-app-harness.md diff --git a/.changeset/browser-app-harness.md b/.changeset/browser-app-harness.md new file mode 100644 index 000000000..ac9df564e --- /dev/null +++ b/.changeset/browser-app-harness.md @@ -0,0 +1,14 @@ +--- +"agent-bundle": minor +--- + +Add the browser-app proof level to the consumer test harness (#103 stage 3). +`agentBundleBrowserRstest()` from `agent-bundle/rstest` compiles every declared +MCP App once per pool run through the production Rsbuild profile and configures +an Rstest browser pool; the new browser-safe `agent-bundle/test/browser` +subpath ships `mountBrowserApp`, which mounts the compiled self-contained HTML +in a sandboxed iframe over the product's own MCP App bridge with test-supplied +binding operations, consent decisions, and captured traffic. The test manifest +now carries collision-checked MCP App descriptors from the same compiler pass, +and `compileMcpApps` accepts a per-app target selection alongside the existing +single-target form.