cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / mcp / testdata / view.mjs
view.mjs 7.9K
1// Draws the MCP view in Chromium the two ways hosts show it, and checks2// what a person would see (see views_browser_test.go):3//4// - openai: the OpenAI Apps SDK's way. window.openai is set before the5// page's scripts run, toolOutput and toolResponseMetadata null at6// first and then set by an openai:set_globals event, as ChatGPT does7// when the result arrives;8// notifyIntrinsicHeight resizes the frame.9// - mcp-apps: the MCP Apps way. The host answers ui/initialize with its10// context, sends ui/notifications/tool-input and tool-result once the11// view says it's initialized, and sizes the frame as12// ui/notifications/size-changed says.13//14// Usage: node view.mjs cases.json [screenshot folder]. It exits 0 when15// every check passes, 3 when there's no browser to run, 1 otherwise.16import { readFileSync } from "node:fs";17import { createRequire } from "node:module";18import { join } from "node:path";1920const require = createRequire(import.meta.url);21const { chromium } = require("playwright");22const [, , casesFile, shots] = process.argv;23const cases = JSON.parse(readFileSync(casesFile, "utf8"));24const failures = [];25const check = (ok, what) => { if (!ok) failures.push(what); };2627let browser;28try {29 browser = await chromium.launch();30} catch (e) {31 console.error(String(e).split("\n")[0]);32 process.exit(3);33}3435// js is v as a script's literal, safe inside a script element.36const js = (v) => JSON.stringify(v).replace(/</g, "\\u003c");3738// openaiDoc is the view's page with window.openai set first.39function openaiDoc(page, args, theme) {40 const boot = `<script>41window.openai={theme:${js(theme)},displayMode:"inline",maxHeight:520,locale:"en-US",42 toolInput:${js(args)},toolOutput:null,toolResponseMetadata:null,widgetState:null,43 setWidgetState:function(){return Promise.resolve()},callTool:function(){return Promise.reject(new Error("no tools here"))},44 notifyIntrinsicHeight:function(h){parent.postMessage({harness:"height",height:h},"*")}};45</script>`;46 return page.replace("<head>", "<head>" + boot);47}4849// hostPage is the page around the frame: it relays the frame's size and,50// as an MCP Apps host, speaks the extension's messages.51function hostPage(mode, doc, c, theme) {52 const hc = { theme, displayMode: "inline", containerDimensions: { maxHeight: 520 }, locale: "en-US", platform: "web" };53 return `<!doctype html><html><body style="margin:24px;background:${theme === "dark" ? "#111" : "#eee"}">54<iframe id="f" sandbox="allow-scripts" style="width:720px;height:150px;border:1px solid #888;display:block"></iframe>55<script>56var f=document.getElementById("f"),seen=[];window.seen=seen;57window.addEventListener("message",function(e){58 if(e.source!==f.contentWindow)return;var m=e.data;seen.push(m);59 if(m.harness==="height"){f.style.height=m.height+"px";return}60 if(${js(mode)}!=="mcp-apps"||!m||m.jsonrpc!=="2.0")return;61 function post(x){f.contentWindow.postMessage(Object.assign({jsonrpc:"2.0"},x),"*")}62 if(m.method==="ui/initialize")post({id:m.id,result:{protocolVersion:"2026-01-26",hostInfo:{name:"harness",version:"1"},hostCapabilities:{},hostContext:${js(hc)}}});63 if(m.method==="ui/notifications/initialized"){post({method:"ui/notifications/tool-input",params:{arguments:${js(c.args)}}});64 post({method:"ui/notifications/tool-result",params:${js(c.result)}})}65 if(m.method==="ui/notifications/size-changed")f.style.height=m.params.height+"px";66});67f.srcdoc=${js(doc)};68</script></body></html>`;69}7071async function run(mode, name, c, theme) {72 const page = await browser.newPage({ viewport: { width: 800, height: 700 } });73 const errors = [];74 page.on("pageerror", (e) => errors.push(String(e)));75 const doc = mode === "openai" ? openaiDoc(cases.page, c.args, theme) : cases.page;76 await page.setContent(hostPage(mode, doc, c, theme));77 const frame = page.frameLocator("#f");78 if (mode === "openai") {79 await frame.locator("#o12-view").waitFor({ state: "attached" });80 await page.waitForTimeout(100);81 await frame.locator("body").evaluate((_, r) => {82 window.openai.toolOutput = r.structuredContent;83 window.openai.toolResponseMetadata = r._meta;84 const globals = { toolOutput: r.structuredContent, toolResponseMetadata: r._meta };85 window.dispatchEvent(new CustomEvent("openai:set_globals", { detail: { globals } }));86 }, c.result);87 }88 const what = `${mode} ${name}`;89 try {90 await frame.locator("#o12-view > *").first().waitFor({ timeout: 5000 });91 } catch {92 failures.push(`${what}: nothing drawn`);93 await page.close();94 return;95 }96 await page.waitForTimeout(300);97 const v = c.result._meta["o12/view"];98 const state = () => frame.locator("body").evaluate(() => {99 const sh = document.querySelector("#o12-view .sheet");100 return {101 book: document.querySelector(".panel .book").textContent,102 name: document.getElementById("o12-name").textContent,103 input: document.getElementById("o12-input").textContent,104 where: document.getElementById("o12-context").textContent,105 theme: document.documentElement.className,106 height: Math.ceil(document.documentElement.getBoundingClientRect().height),107 scroll: sh ? { client: sh.clientHeight, full: sh.scrollHeight } : null,108 };109 });110 let s = await state();111 const frameHeight = await page.locator("#f").evaluate((f) => f.getBoundingClientRect().height);112 check(s.book === v.title && s.where === v.where, `${what}: panel says ${s.book} / ${s.where}`);113 check(s.theme === theme, `${what}: theme ${s.theme}, want ${theme}`);114 check(Math.abs(frameHeight - s.height - 2) <= 2, `${what}: frame ${frameHeight}px for ${s.height}px of view`);115 check(s.height <= 520, `${what}: ${s.height}px, more than the host's room`);116 check(errors.length === 0, `${what}: script errors ${errors}`);117 if (c.grid) {118 check(s.name === "A1" && s.input === c.grid.a1, `${what}: pointer on ${s.name} showing ${s.input}`);119 await frame.locator(`td[data-a="${c.grid.cell}"]`).click();120 s = await state();121 check(s.name === c.grid.cell && s.input === c.grid.input, `${what}: clicking ${c.grid.cell} shows ${s.name} ${s.input}`);122 await page.keyboard.press("ArrowDown");123 s = await state();124 check(s.name === c.grid.below, `${what}: the arrow key moved to ${s.name}`);125 check(s.scroll && s.scroll.full > s.scroll.client, `${what}: the grid doesn't scroll (${JSON.stringify(s.scroll)})`);126 const header = await frame.locator("#o12-view .sheet").evaluate((sh) => {127 sh.scrollTop = 400;128 const th = sh.querySelector("thead th"), top = sh.getBoundingClientRect().top;129 return { scrolled: sh.scrollTop, headerAt: Math.round(th.getBoundingClientRect().top - top) };130 });131 check(header.scrolled > 0 && header.headerAt === 0, `${what}: scrolled ${header.scrolled}, column header ${header.headerAt}px down`);132 await frame.locator("#o12-view .sheet").evaluate((sh) => { sh.scrollTop = 0; });133 const style = await frame.locator(`td[data-a="${c.grid.cell}"]`).evaluate((td) => getComputedStyle(td).textAlign);134 check(style === "right", `${what}: a number is aligned ${style}`);135 } else {136 check(await frame.locator("#o12-view svg").count() === 1, `${what}: no chart drawn`);137 check(s.name === v.name, `${what}: name box ${s.name}`);138 }139 if (mode === "mcp-apps") {140 const seen = await page.evaluate(() => window.seen.map((m) => m.method || (m.id ? "response" : m.harness)));141 check(seen[0] === "ui/initialize" && seen.includes("ui/notifications/initialized") && seen.includes("ui/notifications/size-changed"),142 `${what}: messages ${seen}`);143 }144 if (shots) {145 await page.screenshot({ path: join(shots, `${mode}-${name}-${theme}.png`) });146 }147 await page.close();148}149150for (const mode of ["openai", "mcp-apps"]) {151 for (const [name, c] of Object.entries(cases.cases)) {152 for (const theme of ["light", "dark"]) {153 await run(mode, name, c, theme);154 }155 }156}157await browser.close();158if (failures.length) {159 console.error(failures.join("\n"));160 process.exit(1);161}