From 590bd265f43c32967ddfa432b9112ad96cb03c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20T=C3=BCchler?= Date: Sun, 6 Sep 2026 14:24:47 +0000 Subject: [PATCH] feat: add visual WebMCP Workbench --- CHANGELOG.md | 5 + README.md | 5 +- package.json | 3 +- skills/webmcpify/SKILL.md | 24 +- skills/webmcpify/references/workbench.md | 87 +++ skills/webmcpify/scripts/workbench.mjs | 111 ++++ .../webmcpify/templates/webmcp-workbench.js | 594 ++++++++++++++++++ tests/workbench-browser.mjs | 189 ++++++ tests/workbench.test.mjs | 88 +++ 9 files changed, 1101 insertions(+), 5 deletions(-) create mode 100644 skills/webmcpify/references/workbench.md create mode 100644 skills/webmcpify/scripts/workbench.mjs create mode 100644 skills/webmcpify/templates/webmcp-workbench.js create mode 100644 tests/workbench-browser.mjs create mode 100644 tests/workbench.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1375964..684d86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ reconstruct them from git history. ## [Unreleased] +- Added an agent-launched, dependency-free visual WebMCP Workbench with the + WebMCPify palette, responsive desktop/phone layouts, manifest comparison, + schema-driven arguments, structured results, mutation confirmation, and + explicit `Native` versus `Simulated` evidence. The portable simulation is + development-only and never counts as native verification. - Added native/stub I/O compat: the harness uses an explicit adapter mode — stub `tool.execute(object)` or spec-shaped `mc.executeTool(object)` when `mc.__webmcpStubObjectMode` is set, native `mc.executeTool(JSON string)` otherwise diff --git a/README.md b/README.md index 514b9d6..1e17b4c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Open your agent in the target repo and pick your scope: /webmcpify # full pipeline /webmcpify inventory # just investigate + propose the tool manifest (zero code changes) /webmcpify integrate # integrate the approved manifest +/webmcpify workbench # agent launches the temporary visual tool inspector /webmcpify verify # verify + heal what's integrated /webmcpify status # where are we? what's next? /webmcpify full parity # census every interactive element on every authenticated route @@ -145,8 +146,8 @@ Every phase is a **loop over persistent state**, not a one-shot pass: | Path | Purpose | |---|---| | [`skills/webmcpify/SKILL.md`](skills/webmcpify/SKILL.md) | The pipeline (what your agent follows) | -| [`skills/webmcpify/references/`](skills/webmcpify/references/) | Phase guides: inventory, integrate, runtime, verify, heal, security, discovery | -| [`skills/webmcpify/templates/`](skills/webmcpify/templates/) | Vendorable runtime (TS + JS), ambient types, Playwright verification template, discovery manifest | +| [`skills/webmcpify/references/`](skills/webmcpify/references/) | Phase guides: inventory, integrate, Workbench, runtime, verify, heal, security, discovery | +| [`skills/webmcpify/templates/`](skills/webmcpify/templates/) | Vendorable runtime (TS + JS), temporary visual Workbench, ambient types, Playwright verification template, discovery manifest | ## Status diff --git a/package.json b/package.json index 2f1cce6..b903854 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,10 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.templates.json && tsc -p tsconfig.spec.json && tsc -p tsconfig.jsx.json", - "syntaxcheck": "node --check skills/webmcpify/templates/webmcpify.js", + "syntaxcheck": "node --check skills/webmcpify/templates/webmcpify.js && node --check skills/webmcpify/templates/webmcp-workbench.js && node --check skills/webmcpify/scripts/workbench.mjs", "test": "node --test \"tests/*.test.mjs\"", "check": "npm run typecheck && npm run syntaxcheck && npm test", + "workbench:verify": "node tests/workbench-browser.mjs", "proof:verify": "xvfb-run -a node proof/demo/run.mjs --verify", "proof:record": "xvfb-run -a node proof/demo/run.mjs --record" }, diff --git a/skills/webmcpify/SKILL.md b/skills/webmcpify/SKILL.md index 96df331..05bf0a4 100644 --- a/skills/webmcpify/SKILL.md +++ b/skills/webmcpify/SKILL.md @@ -1,7 +1,7 @@ --- name: webmcpify -description: WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser. Use for "webmcpify", "add WebMCP", or "expose app actions to AI agents". -argument-hint: "[inventory|integrate|verify|status|full] [scope notes]" +description: WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then inspect, verify and heal them in a real browser. Use for "webmcpify", "add WebMCP", or "expose app actions to AI agents". +argument-hint: "[inventory|integrate|workbench|verify|status|full] [scope notes]" license: MIT tags: - webmcp @@ -43,6 +43,7 @@ The user may pass an argument (`/webmcpify ` or plain words): | *(none)* or `full` | all phases, resuming from current manifest state | done | | `inventory` / `map` | DETECT + INVENTORY loops only — **zero code changes** | present the manifest table for review | | `integrate` | INTEGRATE loop only (requires approved tools in the manifest) | integrated + built | +| `workbench` / `inspect` | launch the temporary visual inspector for approved/integrated tools | user closes the session | | `verify` | VERIFY + HEAL loops on integrated/verified tools | green/skipped report | | `status` | read `.webmcpify/manifest.json` — **read-only** | report phase, per-status tool counts, and the recommended next command | @@ -79,6 +80,9 @@ Any other text is scoping guidance (e.g. "only the checkout area", "read-only to 7. **Commits are opt-in.** Never commit unless the human chose a commit policy at the gate (see below). Without git or without permission, leave changes in the working tree and record progress in the manifest only. +8. **Workbench evidence is explicit.** The optional visual Workbench is development- + only and agent-launched (`references/workbench.md`). It must always label evidence + `Native` or `Simulated`; simulated calls never satisfy native verification. ## Fresh, authoritative guidance @@ -367,6 +371,21 @@ a published manifest, and flag one **the pipeline created or modified** since `baselineSha` as an unmapped hunk (a pre-existing, untouched manifest is not your hunk — leave it alone). +## Optional — WORKBENCH (visual inspection) + +When the user asks to inspect or try tools visually, read +`references/workbench.md` and launch the temporary Workbench yourself. Prefer its +Playwright runner: it injects before application code, requires no extension, +flag, command, or project edit from the user, and cleans up when the browser +closes. The panel must visibly say `Native` or `Simulated`. + +Use the approved manifest as Expected evidence and the live page as Observed +evidence. Simulated mode is useful for portable browser/device and responsive +checks, but never changes a tool to `verified`; the normal headed native-browser +loop below remains authoritative. Never ship Workbench in a production entry or +bundle. If a physical device requires temporary dev-entry wiring, the agent adds +and removes it within the same inspection session. + ## Phase 3 — VERIFY (loop) Set up once from `templates/webmcp.spec.ts` per `references/verify.md` (real headed @@ -439,6 +458,7 @@ scope collisions). - `references/inventory.md` — area mapping, naming/schema conventions, budgets/overlap - `references/integrate.md` — declarative + imperative patterns per stack - `references/runtime.md` — vendoring + wiring the `templates/` runtime +- `references/workbench.md` — zero-setup visual inspection and evidence modes - `references/verify.md` — harness setup: flags, surfaces, Playwright/Puppeteer, evals - `references/heal.md` — failure taxonomy → fixes - `references/discovery.md` — optional off-page discovery (manifest, `rel="webmcp"`, diff --git a/skills/webmcpify/references/workbench.md b/skills/webmcpify/references/workbench.md new file mode 100644 index 0000000..d3e1731 --- /dev/null +++ b/skills/webmcpify/references/workbench.md @@ -0,0 +1,87 @@ +# WebMCP Workbench — temporary visual inspection + +Use the Workbench only after the manifest gate. It is a development aid, not +part of the application integration and not proof by itself. + +## Evidence modes + +- **Native** means the page already exposed a browser-provided + `document.modelContext` (or the deprecated `navigator.modelContext`) before + Workbench loaded. Calls use that implementation. +- **Simulated** means Workbench installed its dependency-free in-page transport + before the application entry. This previews registration, schemas, inputs, + execution, results, and responsive UI in browsers without WebMCP. It is never + recorded as native verification. + +Run at least one Native session for a `verified` manifest status. A Simulated +session may diagnose UI/tool wiring and may support the browser/device matrix, +but it only produces portable-preview evidence. + +## Zero-setup launch (preferred) + +The agent owns starting and stopping the session. Do not ask the user to install +an extension, paste a snippet, set a browser flag, or run a command. + +```sh +node /scripts/workbench.mjs \ + --url https://the-recorded-verification-origin.test \ + --manifest .webmcpify/manifest.json \ + --browser chromium +``` + +If the target has no Playwright installation, the launcher provisions a pinned +runtime in the operating system's temporary directory, outside the target repo. +If its requested portable browser engine is absent, it downloads that engine on +first launch. Native mode still requires a locally installed current Chrome. + +Use `--native` for the current Chrome native surface. Use `--browser firefox` or +`--browser webkit` for portable checks when those Playwright engines are +available. The runner injects the inspector before application code and makes no +project changes. Close the launched browser (or interrupt the runner) to clean up. + +## A physical device or an existing browser + +When the user needs the exact device/browser rather than an agent-controlled +browser, the agent may temporarily load `templates/webmcp-workbench.js` before +the app entry in the development HTML and set: + +```html + + +``` + +The agent makes and later removes this temporary development-only wiring. Never +put it in a production entry, build, deployment, service worker, or dependency. +Keep the phone on the same approved development origin; the existing secure- +context and CORS gates still apply. + +## Interaction contract + +- The floating `w` launcher does not cover or dim the host application. +- Desktop uses an anchored panel. At 640px and below it becomes a safe-area-aware + full-screen inspector with a sticky Run action. +- Every session visibly says `Native` or `Simulated`. +- Read-only tools run directly. Server/client/unknown mutations require a + confirmation for every invocation. +- Results stay session-local. There is no chat, API key, bridge, export, snapshot, + persistence, or global invocation history in V1. +- Manifest valid/invalid examples are available as argument presets. The panel + keeps only its own 20 most recent calls in memory for the current session. +- Stop/destroy removes the UI and any Workbench-created simulated context. + +## What to inspect + +Compare Observed registration to the approved manifest: name, description, +parsed schema, mutation class, and annotations. Exercise valid and invalid +arguments. Verify the structured result, visible UI delta, navigation, and +cleanup exactly as required by `references/verify.md`. + +For accessibility and responsive checks, cover keyboard-only operation, visible +focus, screen-reader labels/live results, reduced motion, light/dark preference, +360×800, 768×1024, and desktop. A portable check on several engines does not +replace the native Chrome verification. diff --git a/skills/webmcpify/scripts/workbench.mjs b/skills/webmcpify/scripts/workbench.mjs new file mode 100644 index 0000000..09a4d39 --- /dev/null +++ b/skills/webmcpify/scripts/workbench.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { mkdir, readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const playwrightVersion = '1.61.1'; +const usage = 'Usage: node workbench.mjs --url [--manifest ] [--browser chromium|firefox|webkit] [--native]'; +const value = (flag, fallback) => { + const index = argv.indexOf(flag); + if (index === -1) return fallback; + const result = argv[index + 1]; + if (!result || result.startsWith('--')) throw new Error(`${flag} needs a value.`); + return result; +}; +if (argv.includes('--help')) { + console.log(usage); + process.exit(0); +} +const url = value('--url'); +if (!url) { + console.error(usage); + process.exit(2); +} +const browserName = value('--browser', 'chromium'); +if (!['chromium', 'firefox', 'webkit'].includes(browserName)) throw new Error(`Unsupported browser "${browserName}".`); +const native = argv.includes('--native'); +if (native && browserName !== 'chromium') throw new Error('--native requires Chromium/Chrome.'); + +function run(command, args, options = {}) { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(command, args, { stdio: 'inherit', ...options }); + child.once('error', rejectRun); + child.once('exit', (code, signal) => { + if (code === 0) resolveRun(); + else rejectRun(new Error(`${command} exited with ${signal ? `signal ${signal}` : `code ${code}`}.`)); + }); + }); +} + +async function loadPlaywright() { + const localRequire = createRequire(import.meta.url); + try { + return { playwright: localRequire('playwright'), resolveModule: localRequire.resolve }; + } catch (error) { + if (error?.code !== 'MODULE_NOT_FOUND' || !String(error.message).includes("'playwright'")) throw error; + } + + const scratch = join(tmpdir(), `webmcpify-workbench-playwright-${playwrightVersion}`); + const scratchRequire = createRequire(join(scratch, 'package.json')); + try { + return { playwright: scratchRequire('playwright'), resolveModule: scratchRequire.resolve }; + } catch (error) { + if (error?.code !== 'MODULE_NOT_FOUND' || !String(error.message).includes("'playwright'")) throw error; + } + + await mkdir(scratch, { recursive: true }); + console.error(`Preparing temporary Playwright ${playwrightVersion} runtime outside the target project…`); + await run(process.platform === 'win32' ? 'npm.cmd' : 'npm', [ + 'install', '--no-save', '--no-package-lock', `playwright@${playwrightVersion}`, + ], { cwd: scratch, env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' } }); + return { playwright: scratchRequire('playwright'), resolveModule: scratchRequire.resolve }; +} + +const loaded = await loadPlaywright(); +const { chromium, firefox, webkit } = loaded.playwright; +const browserType = { chromium, firefox, webkit }[browserName]; + +const manifestPath = value('--manifest'); +const manifest = manifestPath ? JSON.parse(await readFile(resolve(manifestPath), 'utf8')) : { tools: [] }; +const source = await readFile(resolve(here, '..', 'templates', 'webmcp-workbench.js'), 'utf8'); +const bootSource = `globalThis.__WEBMCPIFY_WORKBENCH__ = ${JSON.stringify({ expectedTools: manifest.tools ?? [], open: true })};\n${source}`; +const launchOptions = { + headless: false, + ...(native ? { channel: 'chrome', args: ['--enable-features=WebMCP,WebMCPTesting'] } : {}), +}; +let browser; +try { + browser = await browserType.launch(launchOptions); +} catch (error) { + const missingBundledBrowser = !native && /Executable doesn't exist|browserType\.launch: Executable/.test(String(error)); + if (!missingBundledBrowser) throw error; + console.error(`Downloading the temporary ${browserName} browser runtime…`); + await run(process.execPath, [loaded.resolveModule('playwright/cli'), 'install', browserName]); + browser = await browserType.launch(launchOptions); +} +try { + const context = await browser.newContext(); + await context.addInitScript({ content: bootSource }); + const page = await context.newPage(); + page.on('console', (message) => { + if (message.type() === 'error') console.error(`[page] ${message.text()}`); + }); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + const evidence = await page.locator('#webmcpify-workbench').getAttribute('data-evidence'); + console.log(`WebMCP Workbench: ${url} · ${browserName} · ${evidence}`); + console.log('Close the browser or press Ctrl+C to end the temporary session.'); + + await new Promise((resolveDone) => { + const done = () => resolveDone(); + browser.on('disconnected', done); + process.once('SIGINT', done); + process.once('SIGTERM', done); + }); +} finally { + await browser.close().catch(() => {}); +} diff --git a/skills/webmcpify/templates/webmcp-workbench.js b/skills/webmcpify/templates/webmcp-workbench.js new file mode 100644 index 0000000..d08c225 --- /dev/null +++ b/skills/webmcpify/templates/webmcp-workbench.js @@ -0,0 +1,594 @@ +/** + * webmcpify Workbench — vendored from https://github.com/TueJon/webmcpify + * + * MIT License + * Copyright (c) 2026 Jonas Tüchler + * + * Development-only, dependency-free WebMCP inspector. Load before the app entry + * when portable simulation is needed. It never replaces an existing native + * document.modelContext and always labels simulated evidence explicitly. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software — keep this header when + * copying this file into your project. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +(function webmcpifyWorkbench(global) { + 'use strict'; + + const EMPTY_SCHEMA = { type: 'object', properties: {} }; + const state = { instance: null, simulatedContext: null }; + + function parseSchema(raw) { + if (!raw) return EMPTY_SCHEMA; + if (typeof raw === 'string') { + try { return JSON.parse(raw); } catch { return EMPTY_SCHEMA; } + } + return raw; + } + + function json(value) { + if (value === undefined) return 'undefined'; + if (typeof value === 'string') { + try { return JSON.stringify(JSON.parse(value), null, 2); } catch { return value; } + } + try { return JSON.stringify(value, null, 2); } catch { return String(value); } + } + + function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); + } + + function makeSimulationContext(doc) { + const tools = new Map(); + const notify = () => doc.dispatchEvent(new Event('toolchange')); + const context = { + __webmcpStubObjectMode: true, + async registerTool(tool, options = {}) { + if (!tool || typeof tool.name !== 'string' || typeof tool.execute !== 'function') { + throw new TypeError('Simulated WebMCP tools need a name and execute function.'); + } + if (tools.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered.`); + tools.set(tool.name, { public: { ...tool, execute: undefined }, execute: tool.execute }); + options.signal?.addEventListener('abort', () => { + if (tools.delete(tool.name)) notify(); + }, { once: true }); + notify(); + }, + async getTools() { + return Array.from(tools.values(), ({ public: tool }) => ({ ...tool })); + }, + async executeTool(tool, input) { + const registered = tools.get(tool?.name); + if (!registered) throw new Error(`Tool "${tool?.name ?? 'unknown'}" is not registered.`); + const args = typeof input === 'string' ? JSON.parse(input) : input; + return registered.execute(args ?? {}); + }, + }; + return context; + } + + function installContext(doc) { + const nativeContext = doc.modelContext ?? global.navigator?.modelContext; + if (nativeContext) return { context: nativeContext, evidence: 'native' }; + const context = makeSimulationContext(doc); + try { + Object.defineProperty(doc, 'modelContext', { configurable: true, value: context }); + } catch { + doc.modelContext = context; + } + state.simulatedContext = context; + return { context, evidence: 'simulated' }; + } + + function removeSimulatedContext(doc, installed) { + if (installed.evidence !== 'simulated' || state.simulatedContext !== installed.context) return; + if (doc.modelContext === installed.context) { + try { delete doc.modelContext; } catch { /* non-configurable host property */ } + } + state.simulatedContext = null; + } + + function normalizeExpected(raw) { + const source = Array.isArray(raw) ? raw : raw?.tools; + return (source ?? []).filter((tool) => tool && tool.id).map((tool) => ({ + ...tool, + name: tool.name ?? tool.id, + inputSchema: parseSchema(tool.inputSchema), + })); + } + + function declarativeTools(doc) { + return Array.from(doc.querySelectorAll('form[toolname]')).map((form) => { + const properties = {}; + const required = []; + for (const control of Array.from(form.elements)) { + if (!control.name || control.disabled || ['submit', 'button', 'reset'].includes(control.type)) continue; + const definition = { type: ['number', 'range'].includes(control.type) ? 'number' : control.type === 'checkbox' ? 'boolean' : 'string' }; + if (control instanceof global.HTMLSelectElement) definition.enum = Array.from(control.options, (option) => option.value); + const description = control.getAttribute('toolparamdescription') ?? control.closest('fieldset')?.getAttribute('toolparamdescription'); + if (description) definition.description = description; + properties[control.name] = definition; + if (control.required) required.push(control.name); + } + return { + name: form.getAttribute('toolname'), + description: form.getAttribute('tooldescription') ?? '', + inputSchema: { type: 'object', properties, ...(required.length ? { required } : {}) }, + annotations: { readOnlyHint: form.hasAttribute('toolautosubmit') }, + _declarativeForm: form, + }; + }).filter((tool) => tool.name); + } + + async function executeDeclarative(tool, args) { + const form = tool._declarativeForm; + for (const [name, value] of Object.entries(args)) { + const control = form.elements.namedItem(name); + if (!control) continue; + if (control instanceof global.RadioNodeList) { + control.value = String(value); + } else if (control.type === 'checkbox') { + control.checked = Boolean(value); + } else { + control.value = String(value); + } + const target = control instanceof global.RadioNodeList ? control[0] : control; + target?.dispatchEvent(new Event('input', { bubbles: true })); + target?.dispatchEvent(new Event('change', { bubbles: true })); + } + if (!form.reportValidity()) throw new Error('The simulated form arguments failed native HTML validation.'); + form.requestSubmit(); + return { ok: true, simulated: true, submitted: true }; + } + + function createWorkbench(options = {}, preinstalled) { + if (!global.document?.documentElement) throw new Error('WebMCP Workbench needs a document.'); + const doc = global.document; + const installed = preinstalled ?? installContext(doc); + const expected = normalizeExpected(options.expectedTools ?? options.manifest); + const expectedByName = new Map(expected.map((tool) => [tool.name, tool])); + const host = doc.createElement('div'); + host.id = 'webmcpify-workbench'; + host.dataset.evidence = installed.evidence; + host.dataset.secureContext = String(global.isSecureContext === true); + const root = host.attachShadow({ mode: 'open' }); + const css = styles(); + if ('adoptedStyleSheets' in root && global.CSSStyleSheet) { + const sheet = new global.CSSStyleSheet(); + sheet.replaceSync(css); + root.adoptedStyleSheets = [sheet]; + } else { + const style = doc.createElement('style'); + style.textContent = css; + root.append(style); + } + root.append(markup(doc, installed.evidence, global.isSecureContext === true)); + doc.documentElement.append(host); + + const $ = (selector) => root.querySelector(selector); + const ui = { + launcher: $('.launcher'), panel: $('.panel'), close: $('.close'), search: $('.search'), + list: $('.tool-list'), select: $('.tool-select'), title: $('.tool-title'), meta: $('.tool-meta'), + schema: $('.schema'), expected: $('.expected'), fields: $('.fields'), raw: $('.raw-input'), run: $('.run'), + result: $('.result'), resultMeta: $('.result-meta'), live: $('.live'), + confirm: $('.confirm'), confirmText: $('.confirm-text'), confirmRun: $('.confirm-run'), + validExample: $('.valid-example'), invalidExample: $('.invalid-example'), + history: $('.history-list'), historyEmpty: $('.history-empty'), + }; + let tools = []; + let selectedName = ''; + let pendingRun = null; + let toolFingerprint = ''; + let fieldSchema = null; + const history = []; + + function setOpen(open) { + ui.panel.hidden = !open; + ui.launcher.setAttribute('aria-expanded', String(open)); + if (open) { refresh(); requestAnimationFrame(() => (ui.search.offsetParent ? ui.search : ui.select).focus()); } + else ui.launcher.focus(); + } + + function selected() { return tools.find((tool) => tool.name === selectedName); } + + function mutationLabel(tool) { + const expectedTool = expectedByName.get(tool.name); + if (expectedTool?.mutating === false) return 'Read only'; + if (expectedTool?.mutating === 'client') return 'Changes this browser'; + if (expectedTool?.mutating === 'server') return 'Changes server data'; + return 'Mutation unknown'; + } + + function statusLabel(tool) { + if (tool._observed === false) return 'Expected only'; + if (!expectedByName.size) return 'Observed'; + const expectedTool = expectedByName.get(tool.name); + if (!expectedTool) return 'Observed only'; + const schemaMatches = JSON.stringify(canonical(parseSchema(tool.inputSchema))) === JSON.stringify(canonical(expectedTool.inputSchema)); + const annotationsMatch = !expectedTool.annotations || JSON.stringify(canonical(tool.annotations ?? {})) === JSON.stringify(canonical(expectedTool.annotations)); + const descriptionMatches = !expectedTool.description || tool.description === expectedTool.description; + return schemaMatches && annotationsMatch && descriptionMatches ? 'Observed + expected' : 'Contract differs'; + } + + function renderList() { + const query = ui.search.value.trim().toLowerCase(); + const visible = tools.filter((tool) => `${tool.name} ${tool.description ?? ''}`.toLowerCase().includes(query)); + ui.list.replaceChildren(...visible.map((tool) => { + const button = doc.createElement('button'); + button.type = 'button'; + button.className = `tool-row${tool.name === selectedName ? ' active' : ''}`; + button.dataset.name = tool.name; + button.setAttribute('aria-pressed', String(tool.name === selectedName)); + const name = doc.createElement('strong'); name.textContent = tool.name; + const status = doc.createElement('span'); status.textContent = statusLabel(tool); + button.append(name, status); + return button; + })); + ui.select.replaceChildren(...tools.map((tool) => { + const option = doc.createElement('option'); option.value = tool.name; option.textContent = tool.name; + option.selected = tool.name === selectedName; return option; + })); + $('.empty').textContent = tools.length ? 'No tools match this search.' : 'Waiting for tools to register…'; + $('.empty').hidden = visible.length > 0; + } + + function applyArguments(args) { + const plainObject = args && typeof args === 'object' && !Array.isArray(args); + const fieldsCanRepresent = fieldSchema && plainObject + && Object.keys(args).every((name) => Object.hasOwn(fieldSchema, name)); + ui.fields.hidden = !fieldsCanRepresent || !Object.keys(fieldSchema ?? {}).length; + ui.raw.hidden = Boolean(fieldsCanRepresent); + if (!fieldsCanRepresent) { + ui.raw.value = json(args ?? {}); + return; + } + for (const input of ui.fields.querySelectorAll('input, select')) { + const definition = fieldSchema[input.name] ?? {}; + const value = Object.hasOwn(args, input.name) ? args[input.name] : definition.default; + if (input.type === 'checkbox') input.checked = Boolean(value); + else input.value = value === undefined ? '' : String(value); + } + } + + function renderHistory(entry) { + history.unshift(entry); + history.splice(20); + ui.historyEmpty.hidden = history.length > 0; + ui.history.replaceChildren(...history.map((item) => { + const row = doc.createElement('li'); + const heading = doc.createElement('div'); heading.className = 'history-heading'; + const name = doc.createElement('strong'); name.textContent = item.name; + const meta = doc.createElement('span'); meta.textContent = `${item.status} · ${item.duration} ms`; + const output = doc.createElement('pre'); output.textContent = json({ arguments: item.args, result: item.result }); + heading.append(name, meta); row.append(heading, output); return row; + })); + } + + function fieldFor(name, definition, required) { + const label = doc.createElement('label'); label.className = 'field'; + const caption = doc.createElement('span'); + caption.textContent = `${name}${required ? ' *' : ''}`; + let input; + if (Array.isArray(definition.enum)) { + input = doc.createElement('select'); + for (const value of definition.enum) { + const option = doc.createElement('option'); option.value = String(value); option.textContent = String(value); + input.append(option); + } + } else { + input = doc.createElement('input'); + input.type = definition.type === 'number' || definition.type === 'integer' ? 'number' : definition.type === 'boolean' ? 'checkbox' : 'text'; + if (definition.default !== undefined && input.type !== 'checkbox') input.value = String(definition.default); + if (input.type === 'checkbox') input.checked = Boolean(definition.default); + } + input.name = name; input.required = required; input.dataset.type = definition.type ?? 'string'; + if (definition.description) input.setAttribute('aria-description', definition.description); + label.append(caption, input); + return label; + } + + function renderDetail() { + const tool = selected(); + if (!tool) return; + const schema = parseSchema(tool.inputSchema); + ui.title.textContent = tool.name; + ui.meta.textContent = `${mutationLabel(tool)} · ${statusLabel(tool)}`; + ui.schema.textContent = json(schema); + const expectedTool = expectedByName.get(tool.name); + ui.expected.textContent = expectedTool ? json({ + description: expectedTool.description, + inputSchema: expectedTool.inputSchema, + annotations: expectedTool.annotations, + mutating: expectedTool.mutating, + }) : 'Not present in the approved manifest.'; + const properties = schema.properties ?? {}; + const supported = Object.entries(properties).every(([, value]) => ['string', 'number', 'integer', 'boolean'].includes(value?.type ?? 'string')); + fieldSchema = supported ? properties : null; + ui.fields.replaceChildren(...Object.entries(properties).map(([name, definition]) => fieldFor(name, definition, schema.required?.includes(name)))); + const examples = expectedTool?.examples ?? {}; + const hasValid = Object.hasOwn(examples, 'valid'); + const hasInvalid = Object.hasOwn(examples, 'invalid') && examples.invalid !== null; + ui.validExample.hidden = !hasValid; + ui.invalidExample.hidden = !hasInvalid; + applyArguments(hasValid ? examples.valid : {}); + ui.result.textContent = 'Run the tool to inspect its structured result.'; + ui.resultMeta.textContent = 'Not run'; + ui.run.textContent = `Run ${tool.name}`; + ui.run.disabled = tool._observed === false; + } + + function readArguments() { + if (!ui.raw.hidden) return JSON.parse(ui.raw.value || '{}'); + const output = {}; + for (const input of ui.fields.querySelectorAll('input, select')) { + if (input.type === 'checkbox') output[input.name] = input.checked; + else if (input.value !== '') output[input.name] = input.dataset.type === 'number' || input.dataset.type === 'integer' ? Number(input.value) : input.value; + } + return output; + } + + async function execute() { + const tool = selected(); + if (!tool) return; + ui.run.disabled = true; + ui.resultMeta.textContent = 'Running…'; + const started = performance.now(); + let args; + try { + args = readArguments(); + const result = tool._declarativeForm + ? await executeDeclarative(tool, args) + : installed.context.__webmcpStubObjectMode + ? await installed.context.executeTool(tool, args) + : await installed.context.executeTool(tool, JSON.stringify(args)); + ui.result.textContent = json(result); + const duration = Math.round(performance.now() - started); + ui.resultMeta.textContent = `Succeeded · ${duration} ms`; + ui.live.textContent = `${tool.name} succeeded.`; + renderHistory({ name: tool.name, status: 'Succeeded', duration, args, result }); + } catch (error) { + const duration = Math.round(performance.now() - started); + ui.result.textContent = error?.stack ?? String(error); + ui.resultMeta.textContent = `Failed · ${duration} ms`; + ui.live.textContent = `${tool.name} failed.`; + renderHistory({ name: tool.name, status: 'Failed', duration, args, result: error?.message ?? String(error) }); + } finally { ui.run.disabled = false; } + } + + function requestRun() { + const tool = selected(); + if (!tool) return; + if (mutationLabel(tool) === 'Read only') return execute(); + ui.confirmText.textContent = `${tool.name} may change application state. Run it once with the visible arguments?`; + pendingRun = execute; + ui.confirm.showModal(); + } + + async function refresh() { + let observed = []; + try { observed = await installed.context.getTools(); } catch (error) { ui.live.textContent = `Could not enumerate tools: ${error}`; } + const observedNames = new Set(observed.map((tool) => tool.name)); + const portableForms = installed.evidence === 'simulated' ? declarativeTools(doc) : []; + const registeredNames = new Set(observedNames); + const uniqueForms = portableForms.filter((tool) => !registeredNames.has(tool.name)); + for (const tool of uniqueForms) registeredNames.add(tool.name); + const nextTools = [ + ...observed.map((tool) => ({ ...tool, inputSchema: parseSchema(tool.inputSchema), _observed: true })), + ...uniqueForms.map((tool) => ({ ...tool, _observed: true })), + ...expected.filter((tool) => !registeredNames.has(tool.name)).map((tool) => ({ ...tool, _observed: false })), + ]; + const nextFingerprint = JSON.stringify(nextTools.map((tool) => [tool.name, tool.description, tool.inputSchema, tool.annotations, tool._observed])); + if (nextFingerprint === toolFingerprint) return; + toolFingerprint = nextFingerprint; + tools = nextTools; + if (!tools.some((tool) => tool.name === selectedName)) selectedName = tools[0]?.name ?? ''; + renderList(); + if (selectedName) renderDetail(); + else { + ui.title.textContent = 'No tools yet'; + ui.meta.textContent = 'Waiting for registration'; + ui.run.textContent = 'Run tool'; + ui.run.disabled = true; + } + $('.count').textContent = String(observed.length + uniqueForms.length); + } + + ui.launcher.addEventListener('click', () => setOpen(ui.panel.hidden)); + ui.close.addEventListener('click', () => setOpen(false)); + ui.search.addEventListener('input', renderList); + ui.list.addEventListener('click', (event) => { + const row = event.target.closest('.tool-row'); if (!row) return; + selectedName = row.dataset.name; renderList(); renderDetail(); + }); + ui.select.addEventListener('change', () => { selectedName = ui.select.value; renderList(); renderDetail(); }); + ui.run.addEventListener('click', requestRun); + ui.validExample.addEventListener('click', () => applyArguments(expectedByName.get(selectedName)?.examples?.valid)); + ui.invalidExample.addEventListener('click', () => applyArguments(expectedByName.get(selectedName)?.examples?.invalid)); + ui.confirm.addEventListener('close', () => { pendingRun = null; }); + ui.confirmRun.addEventListener('click', () => { const run = pendingRun; pendingRun = null; ui.confirm.close(); run?.(); }); + doc.addEventListener('toolchange', refresh); + installed.context.addEventListener?.('toolchange', refresh); + const refreshTimer = global.setInterval(() => { if (!ui.panel.hidden) refresh(); }, 1000); + const onKeydown = (event) => { if (event.key === 'Escape' && !ui.panel.hidden && !ui.confirm.open) setOpen(false); }; + global.addEventListener('keydown', onKeydown); + + refresh(); + if (options.open === true) setOpen(true); + return { + element: host, + evidence: installed.evidence, + open: () => setOpen(true), + close: () => setOpen(false), + refresh, + destroy() { + doc.removeEventListener('toolchange', refresh); + installed.context.removeEventListener?.('toolchange', refresh); + global.removeEventListener('keydown', onKeydown); + global.clearInterval(refreshTimer); + host.remove(); + removeSimulatedContext(doc, installed); + state.instance = null; + }, + }; + } + + function markup(doc, evidence, secureContext) { + const native = evidence === 'native'; + const make = (tag, options = {}, ...children) => { + const element = doc.createElement(tag); + if (options.className) element.className = options.className; + if (options.id) element.id = options.id; + if (options.text !== undefined) element.textContent = options.text; + if (options.hidden) element.hidden = true; + for (const [name, value] of Object.entries(options.attributes ?? {})) element.setAttribute(name, value); + element.append(...children); + return element; + }; + const button = (className, text, attributes = {}, hidden = false) => make('button', { + className, text, hidden, attributes: { type: 'button', ...attributes }, + }); + const eyebrow = (text) => make('p', { className: 'eyebrow', text }); + const details = (summary, className, content) => make('details', { className }, make('summary', { text: summary }), content); + const blockTitle = (title, trailing) => make('div', { className: 'block-title' }, make('h3', { text: title }), trailing); + const fragment = doc.createDocumentFragment(); + fragment.append(button('launcher', 'w', { + 'aria-label': 'Open WebMCP Workbench', 'aria-expanded': 'false', 'aria-controls': 'wb-panel', + })); + + const panel = make('section', { + className: 'panel', id: 'wb-panel', hidden: true, attributes: { 'aria-label': 'WebMCP Workbench' }, + }); + panel.append(make('header', {}, + make('div', {}, eyebrow('WebMCPify'), make('h1', { text: 'Workbench' })), + make('div', { className: 'header-actions' }, + make('span', { className: `evidence ${evidence}`, text: native ? 'Native' : 'Simulated' }), + button('icon close', '×', { 'aria-label': 'Close Workbench' }), + ), + )); + panel.append(make('div', { + className: 'notice', + text: `${native ? 'Calls use this browser’s WebMCP implementation.' : 'Portable preview only — this is not native browser proof.'} ${secureContext ? 'Secure context.' : 'Not a secure context; native WebMCP cannot register here.'}`, + })); + + const mobileSelect = make('select', { className: 'tool-select', id: 'wb-select' }); + panel.append(make('div', { className: 'mobile-picker' }, + make('label', { text: 'Tool', attributes: { for: 'wb-select' } }), mobileSelect, + )); + const searchCount = make('span', {}, make('b', { className: 'count', text: '0' }), ' tools'); + const aside = make('aside', {}, + make('div', { className: 'search-wrap' }, make('input', { + className: 'search', attributes: { type: 'search', placeholder: 'Find a tool', 'aria-label': 'Find a tool' }, + }), searchCount), + make('div', { className: 'tool-list' }), + make('p', { className: 'empty', text: 'Waiting for tools to register…' }), + ); + const exampleActions = make('div', { className: 'example-actions' }, + button('valid-example', 'Use valid example', {}, true), + button('invalid-example', 'Use invalid example', {}, true), + ); + const argumentsBlock = make('section', { className: 'block' }, + blockTitle('Arguments', exampleActions), + make('div', { className: 'fields' }), + make('textarea', { + className: 'raw-input', text: '{}', attributes: { spellcheck: 'false', 'aria-label': 'JSON arguments' }, + }), + ); + const resultBlock = make('section', { className: 'block result-block' }, + blockTitle('Result', make('span', { className: 'result-meta', text: 'Not run' })), + make('pre', { className: 'result', text: 'Run a tool to inspect its structured result.' }), + ); + const history = details('Workbench call history', 'history', make('div', {}, + make('p', { className: 'history-empty', text: 'No calls in this session.' }), + make('ol', { className: 'history-list' }), + )); + const main = make('main', {}, + make('div', { className: 'tool-heading' }, make('div', {}, eyebrow('Selected tool'), + make('h2', { className: 'tool-title', text: 'No tools yet' }), make('p', { className: 'tool-meta' }))), + argumentsBlock, + details('Observed schema', '', make('pre', { className: 'schema' })), + details('Expected contract', '', make('pre', { className: 'expected' })), + resultBlock, history, + ); + panel.append(make('div', { className: 'body' }, aside, main)); + panel.append(make('footer', {}, + make('span', { text: native ? 'Native verification' : 'Portable simulation' }), + button('run', 'Run tool'), + )); + panel.append(make('p', { className: 'live', attributes: { 'aria-live': 'polite' } })); + const confirmForm = make('form', { attributes: { method: 'dialog' } }, + eyebrow('Confirm mutation'), make('h2', { id: 'wb-confirm-title', text: 'Run this tool?' }), + make('p', { className: 'confirm-text' }), + make('div', {}, button('', 'Cancel', { value: 'cancel', type: 'submit' }), button('confirm-run', 'Run once', { value: 'default' })), + ); + panel.append(make('dialog', { + className: 'confirm', attributes: { 'aria-labelledby': 'wb-confirm-title' }, + }, confirmForm)); + fragment.append(panel); + return fragment; + } + + function styles() { + return ` + :host{--p:#5f3dc4;--p2:#4d31a5;--button-ink:#fff;--bg:#fff;--surface:#f7f5fb;--ink:#26232d;--muted:#6d6777;--line:#e7e2ed;--good:#16845b;all:initial;color-scheme:light dark;font-family:"Familjen Grotesk","Segoe UI",system-ui,sans-serif;color:var(--ink)} + *,*::before,*::after{box-sizing:border-box}button,input,select,textarea{font:inherit}button{cursor:pointer}.launcher{position:fixed;z-index:2147483646;right:max(18px,env(safe-area-inset-right));bottom:max(18px,env(safe-area-inset-bottom));width:48px;height:48px;border:0;border-radius:13px;background:var(--p);color:var(--button-ink);font:800 25px/1 ui-monospace,monospace;box-shadow:0 10px 28px #22163b3d}.launcher:focus-visible,.panel :focus-visible{outline:3px solid color-mix(in srgb,var(--p),white 35%);outline-offset:2px}.panel{position:fixed;z-index:2147483645;right:max(18px,env(safe-area-inset-right));bottom:max(78px,calc(env(safe-area-inset-bottom) + 70px));width:min(760px,calc(100vw - 36px));height:min(680px,calc(100dvh - 110px));background:var(--bg);border:0;border-radius:16px;box-shadow:0 24px 70px #22163b33;overflow:hidden}.panel[hidden]{display:none}.panel>header{height:74px;display:flex;align-items:center;justify-content:space-between;padding:13px 18px;border-bottom:1px solid var(--line)}h1,h2,h3,p{margin:0}h1{font-size:20px;letter-spacing:-.02em}.eyebrow{text-transform:uppercase;letter-spacing:.14em;font:700 10px/1.4 ui-monospace,monospace;color:var(--p)}.header-actions{display:flex;align-items:center;gap:10px}.evidence{padding:6px 9px;border:1px solid currentColor;border-radius:999px;font:700 11px/1 ui-monospace,monospace}.evidence.native{color:var(--good)}.evidence.simulated{color:var(--p)}.icon{width:44px;height:44px;border:0;background:transparent;color:var(--muted);font-size:27px}.notice{padding:8px 18px;background:var(--surface);border-bottom:1px solid var(--line);color:var(--muted);font-size:12px}.body{display:grid;grid-template-columns:250px 1fr;height:calc(100% - 174px)}aside{border-right:1px solid var(--line);overflow:auto}.search-wrap{position:sticky;top:0;padding:14px;background:var(--bg);border-bottom:1px solid var(--line)}.search{width:100%;height:44px;padding:0 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}.search-wrap span{display:block;margin-top:8px;color:var(--muted);font:11px ui-monospace,monospace}.tool-list{padding:8px}.tool-row{width:100%;min-height:54px;padding:9px 10px;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--ink);text-align:left}.tool-row strong,.tool-row span{display:block;overflow:hidden;text-overflow:ellipsis}.tool-row strong{font:700 12px ui-monospace,monospace}.tool-row span{margin-top:5px;color:var(--muted);font-size:11px}.tool-row:hover{background:var(--surface)}.tool-row.active{background:color-mix(in srgb,var(--p),transparent 91%);border-color:color-mix(in srgb,var(--p),transparent 70%)}.empty{padding:18px;color:var(--muted);font-size:12px}main{padding:18px;overflow:auto}.tool-heading{margin-bottom:16px}.tool-title{font:700 19px ui-monospace,monospace;overflow-wrap:anywhere}.tool-meta{margin-top:6px;color:var(--muted);font-size:12px}.block,details{margin-top:12px;border:1px solid var(--line);border-radius:11px;background:var(--bg)}.block-title{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 12px;border-bottom:1px solid var(--line)}h3,summary{font-size:13px}.example-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.example-actions button{min-height:32px;padding:0 8px;border:1px solid var(--line);border-radius:7px;background:var(--surface);color:var(--ink);font-size:11px}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:12px}.field span{display:block;margin-bottom:6px;color:var(--muted);font-size:11px}.field input,.field select,.raw-input{width:100%;min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink)}.field input[type=checkbox]{width:44px}.raw-input{min-height:110px;border:0;border-radius:0 0 10px 10px;resize:vertical;font:12px/1.55 ui-monospace,monospace}details summary{display:flex;align-items:center;min-height:44px;padding:10px 12px;cursor:pointer}.schema,.expected,.result,.history-list pre{margin:0;padding:12px;max-height:190px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:var(--surface);color:var(--ink);font:11px/1.55 ui-monospace,monospace}.result-block{margin-bottom:18px}.history-empty{padding:0 12px 12px;color:var(--muted);font-size:12px}.history-list{margin:0;padding:0;list-style:none}.history-list li{border-top:1px solid var(--line)}.history-heading{display:flex;justify-content:space-between;gap:10px;padding:10px 12px;font-size:11px}.history-heading strong{overflow-wrap:anywhere}.history-heading span{color:var(--muted);white-space:nowrap}.panel>footer{position:absolute;left:250px;right:0;bottom:0;height:58px;display:flex;align-items:center;justify-content:space-between;padding:8px 18px;border-top:1px solid var(--line);background:var(--bg)}footer span{color:var(--muted);font:11px ui-monospace,monospace}.run,.confirm-run{min-height:44px;padding:0 16px;border:0;border-radius:9px;background:var(--p);color:var(--button-ink);font-weight:750}.run:hover,.confirm-run:hover{background:var(--p2)}.run:disabled{opacity:.5}.live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.confirm{max-width:380px;border:0;border-radius:14px;background:var(--bg);color:var(--ink);box-shadow:0 24px 70px #22163b4d}.confirm::backdrop{background:#17131f66}.confirm form{padding:6px}.confirm h2{margin-top:4px}.confirm-text{margin:12px 0 20px;color:var(--muted);line-height:1.45}.confirm form>div{display:flex;justify-content:flex-end;gap:8px}.confirm form button{min-height:44px;padding:0 14px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}.confirm form .confirm-run{border:0;background:var(--p);color:var(--button-ink)}.mobile-picker{display:none} + @media(prefers-color-scheme:dark){:host{--bg:#1b1821;--surface:#25212c;--ink:#f5f1fa;--muted:#aaa2b5;--line:#3b3544;--p:#9b7af0;--p2:#aa8df5;--button-ink:#17121d;--good:#5ad4a4}} + @media(max-width:640px){.launcher{right:max(12px,env(safe-area-inset-right));bottom:max(12px,env(safe-area-inset-bottom))}.launcher[aria-expanded=true]{display:none}.panel{inset:0;width:100vw;height:100dvh;border:0;border-radius:0;padding-top:env(safe-area-inset-top)}.panel>header{height:68px}.notice{padding:8px 14px}.mobile-picker{display:block;padding:10px 14px;border-bottom:1px solid var(--line)}.mobile-picker label{display:block;margin-bottom:5px;color:var(--muted);font-size:11px}.tool-select{width:100%;height:44px;padding:0 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink)}.body{display:block;height:calc(100% - 229px)}aside{display:none}main{height:100%;padding:14px}.fields{grid-template-columns:1fr}.panel>footer{left:0;height:70px;padding:9px 14px calc(9px + env(safe-area-inset-bottom))}.run{min-height:48px;max-width:70%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}} + @media(prefers-reduced-motion:no-preference){.panel{animation:wb-in .16s ease-out}.launcher{transition:transform .15s ease}.launcher:hover{transform:translateY(-2px)}@keyframes wb-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}} + `; + } + + const api = { + start(options = {}) { + if (state.instance) return state.instance; + if (!global.document) throw new Error('WebMCP Workbench needs a document.'); + const installed = installContext(global.document); + if (!global.document.documentElement) { + let cancelled = false; + let resolveReady; + const onReady = () => { + if (!cancelled) state.instance = createWorkbench(options, installed); + resolveReady(state.instance); + }; + const pending = { + evidence: installed.evidence, + ready: new Promise((resolve) => { resolveReady = resolve; }), + destroy() { + cancelled = true; + global.document.removeEventListener('DOMContentLoaded', onReady); + removeSimulatedContext(global.document, installed); + state.instance = null; + resolveReady(null); + }, + }; + global.document.addEventListener('DOMContentLoaded', onReady, { once: true }); + state.instance = pending; + return pending; + } + state.instance = createWorkbench(options, installed); + return state.instance; + }, + stop() { state.instance?.destroy(); }, + parseSchema, + formatResult: json, + createSimulationContext: makeSimulationContext, + }; + global.WebMCPifyWorkbench = api; + if (global.__WEBMCPIFY_WORKBENCH__) api.start(global.__WEBMCPIFY_WORKBENCH__); +})(globalThis); diff --git a/tests/workbench-browser.mjs b/tests/workbench-browser.mjs new file mode 100644 index 0000000..ad7af8d --- /dev/null +++ b/tests/workbench-browser.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, firefox, webkit } from 'playwright'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const source = await readFile(join(root, 'skills/webmcpify/templates/webmcp-workbench.js'), 'utf8'); +const output = '/tmp/webmcpify-workbench-browser'; +await mkdir(output, { recursive: true }); + +function contrastRatio(foreground, background) { + const channels = (color) => color.match(/[\d.]+/g).slice(0, 3).map((value) => { + const channel = Number(value) / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + const luminance = (color) => { const [r, g, b] = channels(color); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; + const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a); + return (values[0] + 0.05) / (values[1] + 0.05); +} + +const html = `Workbench fixture + +

Ticket desk

Host application stays interactive.

+
+`; + +const server = createServer((request, response) => { + if (request.url === '/strict-csp') { + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'content-security-policy': "default-src 'none'; style-src 'none'; require-trusted-types-for 'script'", + }); + response.end('Strict CSP

Host

'); + return; + } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + response.end(html); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const url = `http://127.0.0.1:${server.address().port}`; +const engines = { chromium, firefox, webkit }; +const requested = process.argv.slice(2).filter((name) => engines[name]); +const names = requested.length ? requested : ['chromium']; + +try { + for (const name of names) { + const browser = await engines[name].launch({ headless: true }); + try { + for (const fixture of [ + { id: 'desktop-light', viewport: { width: 1280, height: 800 }, colorScheme: 'light', reducedMotion: 'no-preference' }, + { id: 'phone-dark', viewport: { width: 360, height: 800 }, colorScheme: 'dark', reducedMotion: 'reduce' }, + { id: 'tablet-light', viewport: { width: 768, height: 1024 }, colorScheme: 'light', reducedMotion: 'reduce' }, + ]) { + const context = await browser.newContext(fixture); + const expectedTools = [ + { id: 'search_tickets', mutating: false, description: 'Searches tickets by query.', inputSchema: { type: 'object', properties: { query: { type: 'string', default: 'billing' }, limit: { type: 'integer', default: 5 } }, required: ['query'] }, annotations: { readOnlyHint: true, untrustedContentHint: false }, examples: { valid: { query: 'billing', limit: 5 }, invalid: { query: '' } } }, + { id: 'close_ticket', mutating: 'server', description: 'Closes one ticket.', inputSchema: { type: 'object', properties: { id: { type: 'string', default: 'T-42' } }, required: ['id'] }, annotations: { readOnlyHint: false, untrustedContentHint: false } }, + { id: 'filter_tickets', mutating: false, description: 'Filters the ticket list.', inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['open', 'closed'], description: 'Ticket status' } } }, annotations: { readOnlyHint: true } }, + { id: 'missing_expected_tool', mutating: false, inputSchema: { type: 'object', properties: {} } }, + ]; + await context.addInitScript({ content: `globalThis.__WEBMCPIFY_WORKBENCH__ = ${JSON.stringify({ expectedTools, open: true })};\n${source}` }); + const page = await context.newPage(); + await page.goto(url); + const host = page.locator('#webmcpify-workbench'); + assert.equal(await host.getAttribute('data-evidence'), 'simulated'); + assert.equal(await host.getAttribute('data-secure-context'), 'true'); + const panel = host.locator('.panel'); + await panel.waitFor(); + assert.equal(await host.locator('.evidence').textContent(), 'Simulated'); + assert.equal(await host.locator('.tool-row').count(), 4); + if (fixture.viewport.width <= 640) await host.locator('.tool-select').selectOption('search_tickets'); + else await host.locator('.tool-row', { hasText: 'search_tickets' }).click(); + assert.equal(await host.locator('input[name="query"]').inputValue(), 'billing'); + await host.locator('.invalid-example').click(); + assert.equal(await host.locator('input[name="query"]').inputValue(), ''); + await host.locator('.valid-example').click(); + assert.equal(await host.locator('input[name="query"]').inputValue(), 'billing'); + await host.locator('input[name="query"]').fill('refund'); + await host.locator('.run').click(); + await page.waitForFunction(() => document.querySelector('#host-result').textContent.includes('refund')); + assert.match(await host.locator('.result').textContent(), /"count": 3/); + assert.equal(await host.locator('.history-list li').count(), 1); + assert.match(await host.locator('.history-list').textContent(), /search_ticketsSucceeded/); + assert.match(await host.locator('.tool-meta').textContent(), /Observed \+ expected/); + const panelOverflow = await panel.evaluate((element) => ({ width: element.clientWidth, scrollWidth: element.scrollWidth })); + assert.ok(panelOverflow.scrollWidth <= panelOverflow.width, 'panel must not overflow horizontally'); + const runColors = await host.locator('.run').evaluate((element) => { + const style = getComputedStyle(element); + return { foreground: style.color, background: style.backgroundColor }; + }); + assert.ok(contrastRatio(runColors.foreground, runColors.background) >= 4.5, 'Run button must meet WCAG AA contrast'); + if (fixture.viewport.width <= 640) { + assert.equal(await host.locator('.launcher').isVisible(), false, 'launcher must not cover the mobile action bar'); + assert.ok((await host.locator('.run').boundingBox()).height >= 44); + } + if (fixture.id === 'desktop-light') { + await host.locator('.search').fill('no-such-tool'); + assert.equal(await host.locator('.empty').textContent(), 'No tools match this search.'); + await host.locator('.search').fill(''); + await host.locator('.tool-row', { hasText: 'close_ticket' }).click(); + await host.locator('.run').click(); + assert.equal(await host.locator('.confirm').getAttribute('open'), ''); + await host.locator('.confirm-run').click(); + await page.waitForFunction(() => document.querySelector('#webmcpify-workbench').shadowRoot.querySelector('.result').textContent.includes('closed')); + await host.locator('.tool-row', { hasText: 'missing_expected_tool' }).click(); + assert.equal(await host.locator('.run').isDisabled(), true); + await host.locator('.tool-row', { hasText: 'filter_tickets' }).click(); + await host.locator('select[name="status"]').selectOption('closed'); + await host.locator('.run').click(); + await page.waitForFunction(() => document.querySelector('#host-result').textContent === 'Filtered closed'); + assert.match(await host.locator('.result').textContent(), /"simulated": true/); + await host.locator('.tool-row', { hasText: 'search_tickets' }).click(); + await host.locator('.run').click(); + await page.waitForTimeout(1100); + assert.match(await host.locator('.result').textContent(), /"count": 3/, 'polling must not erase a result'); + } + await page.screenshot({ path: join(output, `${name}-${fixture.id}.png`) }); + await page.keyboard.press('Escape'); + assert.equal(await panel.isHidden(), true); + assert.equal(await host.evaluate((element) => element.shadowRoot.activeElement?.classList.contains('launcher')), true); + if (fixture.id === 'desktop-light') { + await page.evaluate(() => globalThis.WebMCPifyWorkbench.stop()); + assert.equal(await page.locator('#webmcpify-workbench').count(), 0); + assert.equal(await page.evaluate(() => document.modelContext), undefined); + assert.equal(await page.evaluate(() => globalThis.WebMCPifyWorkbench.start().evidence), 'simulated'); + assert.equal(await page.locator('#webmcpify-workbench').count(), 1); + } + await context.close(); + } + console.log(`${name}: desktop, phone dark/reduced-motion, tablet passed`); + + if (name === 'chromium') { + const context = await browser.newContext({ viewport: { width: 1100, height: 760 } }); + const nativePrelude = ` + const tools = new Map(); + Object.defineProperty(document, 'modelContext', { value: { + async registerTool(tool) { tools.set(tool.name, tool); }, + async getTools() { return Array.from(tools.values(), tool => ({ ...tool, execute: undefined })); }, + async executeTool(tool, input) { + if (typeof input !== 'string') throw new TypeError('native input must be a string'); + return JSON.stringify(await tools.get(tool.name).execute(JSON.parse(input))); + } + }}); + globalThis.__WEBMCPIFY_WORKBENCH__ = ${JSON.stringify({ expectedTools: [{ id: 'search_tickets', mutating: false }], open: true })}; + `; + await context.addInitScript({ content: `${nativePrelude}\n${source}` }); + const page = await context.newPage(); + await page.goto(url); + const host = page.locator('#webmcpify-workbench'); + assert.equal(await host.getAttribute('data-evidence'), 'native'); + assert.equal(await host.locator('.evidence').textContent(), 'Native'); + await host.locator('input[name="query"]').fill('native'); + await host.locator('.run').click(); + await page.waitForFunction(() => document.querySelector('#host-result').textContent.includes('native')); + assert.match(await host.locator('.result').textContent(), /"count": 3/); + await context.close(); + console.log('chromium: native transport path passed'); + + const cspContext = await browser.newContext({ viewport: { width: 900, height: 700 } }); + await cspContext.addInitScript({ content: `globalThis.__WEBMCPIFY_WORKBENCH__ = { open: true };\n${source}` }); + const cspPage = await cspContext.newPage(); + await cspPage.goto(`${url}/strict-csp`); + const cspHost = cspPage.locator('#webmcpify-workbench'); + await cspHost.locator('.panel').waitFor(); + assert.equal(await cspHost.locator('.launcher').evaluate((element) => getComputedStyle(element).position), 'fixed'); + assert.equal(await cspHost.getAttribute('data-evidence'), 'simulated'); + await cspContext.close(); + console.log('chromium: strict CSP and Trusted Types passed'); + } + } finally { await browser.close(); } + } +} finally { + await new Promise((resolve) => server.close(resolve)); +} diff --git a/tests/workbench.test.mjs b/tests/workbench.test.mjs new file mode 100644 index 0000000..4ef67e9 --- /dev/null +++ b/tests/workbench.test.mjs @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const source = readFileSync(join(root, 'skills/webmcpify/templates/webmcp-workbench.js'), 'utf8'); +const runnerSource = readFileSync(join(root, 'skills/webmcpify/scripts/workbench.mjs'), 'utf8'); + +function loadApi(globals = {}) { + const sandbox = { globalThis: { ...globals }, Event }; + sandbox.globalThis.globalThis = sandbox.globalThis; + vm.runInNewContext(source, sandbox, { filename: 'webmcp-workbench.js' }); + return sandbox.globalThis.WebMCPifyWorkbench; +} + +test('workbench is a classic dependency-free script with no browser side effect by default', () => { + const api = loadApi(); + assert.equal(typeof api.start, 'function'); + assert.equal(typeof api.stop, 'function'); + assert.ok(!source.includes('fetch(')); + assert.ok(!source.includes('localStorage')); +}); + +test('launcher can provision Playwright outside a target project', () => { + assert.ok(!runnerSource.includes("from 'playwright'")); + assert.match(runnerSource, /tmpdir\(\)/); + assert.match(runnerSource, /PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD/); +}); + +test('workbench parses native string schemas and formats structured results', () => { + const api = loadApi(); + assert.equal(JSON.stringify(api.parseSchema('{"type":"object","properties":{"q":{"type":"string"}}}')), JSON.stringify({ + type: 'object', properties: { q: { type: 'string' } }, + })); + assert.equal(api.formatResult({ ok: true }), '{\n "ok": true\n}'); + assert.equal(api.formatResult('plain text'), 'plain text'); +}); + +test('simulated context registers, enumerates, executes and aborts tools', async () => { + const api = loadApi(); + const events = []; + const doc = { dispatchEvent: (event) => events.push(event.type) }; + const context = api.createSimulationContext(doc); + const controller = new AbortController(); + await context.registerTool({ + name: 'search_tickets', + inputSchema: { type: 'object' }, + execute: async ({ q }) => ({ count: q.length }), + }, { signal: controller.signal }); + const [tool] = await context.getTools(); + assert.equal(tool.name, 'search_tickets'); + assert.equal(tool.execute, undefined, 'public simulated shape mirrors native RegisteredTool'); + assert.deepEqual(await context.executeTool(tool, { q: 'bug' }), { count: 3 }); + controller.abort(); + assert.equal((await context.getTools()).length, 0); + assert.deepEqual(events, ['toolchange', 'toolchange']); +}); + +test('simulated context rejects duplicate and malformed registrations', async () => { + const api = loadApi(); + const context = api.createSimulationContext({ dispatchEvent() {} }); + await assert.rejects(() => context.registerTool({ name: 'broken' }), /execute function/); + await context.registerTool({ name: 'one', execute() {} }); + await assert.rejects(() => context.registerTool({ name: 'one', execute() {} }), /already registered/); +}); + +test('stopping before DOM readiness removes the pending simulation context', async () => { + const listeners = new Map(); + const document = { + documentElement: null, + addEventListener(type, listener) { listeners.set(type, listener); }, + removeEventListener(type, listener) { + if (listeners.get(type) === listener) listeners.delete(type); + }, + dispatchEvent() {}, + }; + const api = loadApi({ document, navigator: {} }); + const pending = api.start(); + assert.equal(pending.evidence, 'simulated'); + assert.equal(document.modelContext !== undefined, true); + api.stop(); + assert.equal(await pending.ready, null); + assert.equal(document.modelContext, undefined); + assert.equal(listeners.has('DOMContentLoaded'), false); +});