import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { expect, it } from "vitest"; import { launchVerificationServer, runControlOmb, type VerificationServer } from "../control-omb.ts"; import { waitForExit } from "../../server/testing/cleanup.ts"; import { ensureUiBrowser } from "./control-omb-ui.ts"; import { fixtureApi, mountPreview, type MountedPreview } from "./preview-fixture.ts"; import { NOTICES } from "../../src/lib/notices.ts"; const enabled = process.env.OMB_UI_E2E === "1"; if (!enabled) console.log("skipping mobile code-block UI: set OMB_UI_E2E=1 for isolated browser qualification"); const snippets = [ " First plaintext block\n Keep this indentation.\n\n", `const message = "${"a_long_value_".repeat(30)}";\nconsole.log(message);`, `unknown-language ${"unbroken".repeat(50)}\n Preserve the second line.`, "No language was specified.\n This belongs only to the fourth block.", "indented block\n preserved second line", ]; const reply = [ "These independent examples have their own controls.", `\`\`\`text\n${snippets[0]}\n\`\`\``, `\`\`\`typescript\n${snippets[1]}\n\`\`\``, `\`\`\`unrecognised-fixture-language\n${snippets[2]}\n\`\`\``, `\`\`\`\n${snippets[3]}\n\`\`\``, snippets[4].split("\n").map(line => ` ${line}`).join("\n"), "This neighboring prose must never be copied with a block.", ].join("\n\n"); const blocks = `Array.from(document.querySelectorAll('.chat-code-block')).filter(element => element.querySelector('pre') && element.querySelector('[data-code-plate]'))`; // Each block's plate: Copy, then a menu with Wrap lines. const COPY = `[data-code-plate] > button`; const MENU = `[data-code-plate] button[aria-haspopup="menu"]`; async function connect(url: string) { const socket = new WebSocket(url); const pending = new Map(); let next = 0; socket.addEventListener("message", event => { const value = JSON.parse(String(event.data)); const waiting = pending.get(value.id); if (!waiting) return; pending.delete(value.id); if (value.error) waiting.reject(new Error(value.error.message)); else waiting.resolve(value.result); }); const rejectPending = () => { for (const waiting of pending.values()) waiting.reject(new Error("Fixture Chrome connection closed")); pending.clear(); }; socket.addEventListener("close", rejectPending); await new Promise((resolve, reject) => { const timer = setTimeout(() => { socket.close(); reject(new Error("Fixture Chrome connection timed out")); }, 20_000); socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); socket.addEventListener("error", () => { clearTimeout(timer); socket.close(); reject(new Error("Fixture Chrome connection failed")); }, { once: true }); }); return { close() { rejectPending(); socket.close(); }, request(method: string, params: unknown = {}, sessionId?: string): Promise { const id = ++next; return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Fixture Chrome ${method} timed out`)); }, 30_000); pending.set(id, { resolve(value) { clearTimeout(timer); resolve(value); }, reject(error) { clearTimeout(timer); reject(error); } }); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); }, }; } (enabled ? it : it.skip)("keeps each mobile text/code block copyable, wrappable and reachable without hover", async () => { let fixture: VerificationServer | undefined; let preview: MountedPreview | undefined; let chrome: ChildProcess | undefined; let cdp: Awaited> | undefined; let evidence: string | undefined; const receipts: Record = { geometry: [], copied: [], wrapping: [], clipboard: "Chrome Clipboard API read/write after real pointer events; permission granted only to the isolated fixture origin; refusal stubbed afterward" }; try { const tools = await ensureUiBrowser(); if (!tools.chrome) throw new Error("This recipe needs the pinned Chrome for Testing"); fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_REPLIES: JSON.stringify([reply]) }); evidence = process.env.OMB_MOBILE_BLOCKS_EVIDENCE_DIR || join(process.env.OMB_UI_EVIDENCE_DIR || dirname(fixture.info.logPath), "mobile-code-blocks"); mkdirSync(evidence, { recursive: true }); const save = () => writeFileSync(join(evidence!, "receipt.json"), JSON.stringify(receipts, null, 2)); const api = fixtureApi(fixture.info.url); await api("PATCH", "/api/config", { language: "en" }); const { bots } = await api("GET", "/api/bots"); for (const bot of bots.filter((bot: { defaultBot?: unknown }) => bot.defaultBot)) await api("PATCH", `/api/bots/${bot.id}`, { welcomed: true }); const created = await runControlOmb(["new-bot", "--name", "Pepper", "--url", fixture.info.url]) as { bot: { id: string; activeTaskId: string } }; preview = await mountPreview(fixture, { entry: "/scripts/testing/threads-preview.tsx", route: "/__mobile-blocks.html", title: "Isolated mobile code blocks", logLevel: "warn" }); const profile = join(fixture.info.dataDir, "chrome"); mkdirSync(profile, { recursive: true }); // A hosted Linux runner has no mouse, so headless Chrome there reports no hover and no pointer. These settings give // the page a fine, hovering pointer on every host. Any touch-emulation call, even one that turns it off, swaps in the // host's own pointer again, so the desktop pass below runs before touch emulation is ever touched. const mouse = "--blink-settings=primaryPointerType=4,availablePointerTypes=4,primaryHoverType=2,availableHoverTypes=2"; chrome = spawn(tools.chrome, ["--headless=new", "--no-sandbox", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--password-store=basic", "--use-mock-keychain", mouse, "--remote-debugging-port=0", `--user-data-dir=${profile}`, "about:blank"], { env: { PATH: process.env.PATH, HOME: fixture.info.dataDir, USERPROFILE: fixture.info.dataDir, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) }, stdio: "ignore", }); const portFile = join(profile, "DevToolsActivePort"); await expect.poll(() => { if (chrome!.exitCode !== null || chrome!.signalCode !== null) throw new Error("Fixture Chrome exited before startup"); return existsSync(portFile); }, { timeout: 30_000 }).toBe(true); const [port, endpoint] = readFileSync(portFile, "utf8").trim().split("\n"); cdp = await connect(`ws://127.0.0.1:${port}${endpoint}`); const target = await cdp.request("Target.createTarget", { url: "about:blank" }); const { sessionId } = await cdp.request("Target.attachToTarget", { targetId: target.targetId, flatten: true }); const request = (method: string, params: unknown = {}) => cdp!.request(method, params, sessionId); const evaluate = async (expression: string) => { const response = await request("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text); return response.result.value; }; const screenshot = async (name: string) => { const shot = await request("Page.captureScreenshot", { format: "png" }); writeFileSync(join(evidence!, name), Buffer.from(shot.data, "base64")); }; await request("Page.enable"); await request("Runtime.enable"); await request("Emulation.setDeviceMetricsOverride", { width: 1100, height: 900, deviceScaleFactor: 1, mobile: false }); await cdp.request("Browser.grantPermissions", { origin: new URL(preview.previewUrl).origin, permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"] }); // The notices (the Boat card and the like) are not this test's subject, and on a phone one covers the last block. await request("Page.addScriptToEvaluateOnNewDocument", { source: `try { for (const notice of ${JSON.stringify(NOTICES)}) localStorage.setItem(notice, "1"); } catch {}` }); await request("Page.navigate", { url: preview.previewUrl }); await expect.poll(() => evaluate(`Boolean(document.querySelector('textarea[aria-label="Message Pepper"]'))`), { timeout: 120_000 }).toBe(true); await evaluate(`document.querySelector('textarea[aria-label="Message Pepper"]').focus()`); await request("Input.insertText", { text: "Show separate text and code examples" }); await request("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }); await request("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }); const settled = await runControlOmb(["wait", "--bot", created.bot.id, "--task", created.bot.activeTaskId, "--timeout", "60", "--url", fixture.info.url]); receipts.settled = settled; await expect.poll(() => evaluate(`${blocks}.length`), { timeout: 30_000 }).toBe(snippets.length); await expect.poll(() => evaluate(`${blocks}[1].querySelector('pre')?.classList.contains('shiki')`), { timeout: 30_000 }).toBe(true); receipts.highlighted = true; await evaluate(`window.fixtureClipboardWrite = navigator.clipboard.writeText.bind(navigator.clipboard); true`); const geometry = () => evaluate(`(() => { const rect = element => { const r = element.getBoundingClientRect(); return { left:r.left, right:r.right, top:r.top, bottom:r.bottom, width:r.width, height:r.height }; }; return { width:innerWidth, pageWidth:document.documentElement.scrollWidth, coarse:matchMedia('(pointer:coarse)').matches, hover:matchMedia('(hover:hover)').matches, blocks:${blocks}.map(card => ({ ...rect(card), plateOpacity:getComputedStyle(card.querySelector('[data-code-plate]')).opacity, buttons:Array.from(card.querySelectorAll('[data-code-plate] button')).map(button=>({ ...rect(button), label:button.getAttribute('aria-label') })) })) }; })()`); const tapAt = async (point: { x: number; y: number }, touch: boolean) => { if (touch) { await request("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] }); await request("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); } else { await request("Input.dispatchMouseEvent", { type: "mousePressed", ...point, button: "left", clickCount: 1 }); await request("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", clickCount: 1 }); } }; const tap = async (index: number, kind: "copy" | "menu", touch = true) => { const selector = kind === "copy" ? COPY : MENU; let position: { x: number; y: number; hit: boolean }; await expect.poll(async () => { position = await evaluate(`(async () => { const card=${blocks}[${index}]; const button=card.querySelector(${JSON.stringify(selector)}); if(!button) throw new Error('Missing ${kind} control'); button.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'}); await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); const before=button.getBoundingClientRect(); await new Promise(requestAnimationFrame); const r=button.getBoundingClientRect(); const x=r.left+r.width/2,y=r.top+r.height/2; const stable=Math.abs(before.x-r.x)<0.25&&Math.abs(before.y-r.y)<0.25; return {x,y,hit:stable&&button.contains(document.elementFromPoint(x,y))}; })()`); return position.hit; }, { timeout: 10_000 }).toBe(true); await tapAt({ x: position!.x, y: position!.y }, touch); }; // Wrap lines lives in the block's menu, a sheet over the page. const toggleWrap = async (index: number) => { await tap(index, "menu"); const wrapItem = `document.querySelector('[role="menu"] [data-menu-key="wrap"]')`; let point: { x: number; y: number } | null = null; await expect.poll(async () => { point = await evaluate(`(() => { const item=${wrapItem}; if(!item) return null; const r=item.getBoundingClientRect(); const x=r.left+r.width/2,y=r.top+r.height/2; return item.contains(document.elementFromPoint(x,y)) ? {x,y} : null; })()`); return point !== null; }, { timeout: 10_000 }).toBe(true); await tapAt(point!, true); await expect.poll(() => evaluate(`Boolean(${wrapItem})`), { timeout: 5_000 }).toBe(false); }; for (const [width, height, touch] of [[1100, 900, false], [390, 844, true], [320, 740, true], [844, 390, true]] as const) { if (touch) await request("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); await request("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: touch }); await evaluate(`new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))`); await expect.poll(() => evaluate(`(async () => { const card=${blocks}[0]; card.scrollIntoView({behavior:'instant',block:'start'}); await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); const before=card.getBoundingClientRect(); await new Promise(requestAnimationFrame); const after=card.getBoundingClientRect(); return after.top>=0&&after.top evaluate("innerWidth")).toBe(width); // On touch the plates fade in; measure once they are fully shown. if (touch) await expect.poll(() => evaluate(`${blocks}.every(card => getComputedStyle(card.querySelector('[data-code-plate]')).opacity === '1')`), { timeout: 5_000 }).toBe(true); const layout = await geometry(); receipts.geometry.push(layout); await screenshot(`${touch ? "touch" : "desktop"}-${width}.png`); save(); expect(layout.pageWidth).toBeLessThanOrEqual(width); expect(layout.coarse).toBe(touch); expect(layout.hover).toBe(!touch); for (const card of layout.blocks) { expect(card.left).toBeGreaterThanOrEqual(0); expect(card.right).toBeLessThanOrEqual(width); // Without hover the plate is always shown, so nothing needs a hover to be reached. if (touch) expect(card.plateOpacity).toBe("1"); expect(card.buttons.map((button: { label: string }) => button.label)).toEqual([expect.stringMatching(/^(?:Copy code|Copied to clipboard)$/), "More"]); for (const button of card.buttons) { expect(button.left).toBeGreaterThanOrEqual(card.left); expect(button.right).toBeLessThanOrEqual(card.right); if (touch) { expect(button.width).toBeGreaterThanOrEqual(44); expect(button.height).toBeGreaterThanOrEqual(44); } } } await tap(0, "copy", touch); await expect.poll(() => evaluate("navigator.clipboard.readText()")).toBe(snippets[0]); } await request("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); await request("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); for (let index = 0; index < snippets.length; index++) { await tap(index, "copy"); await expect.poll(() => evaluate("navigator.clipboard.readText()")).toBe(snippets[index]); receipts.copied.push({ index, text: await evaluate("navigator.clipboard.readText()") }); } const wraps = () => evaluate(`${blocks}.map(card => String(getComputedStyle(card.querySelector('pre')).whiteSpace === 'pre-wrap'))`); expect(await wraps()).toEqual(snippets.map(() => "false")); for (const index of [1, 2]) { await toggleWrap(index); await expect.poll(wraps).toEqual(snippets.map((_, at) => at === index ? "true" : "false")); const wrapped = await evaluate(`(() => { const card=${blocks}[${index}]; const pre=card.querySelector('pre'); return {whiteSpace:getComputedStyle(pre).whiteSpace,client:pre.clientWidth,scroll:pre.scrollWidth,text:pre.textContent}; })()`); expect(wrapped.whiteSpace).toBe("pre-wrap"); expect(wrapped.scroll).toBeLessThanOrEqual(wrapped.client + 1); expect(wrapped.text).toBe(snippets[index]); receipts.wrapping.push({ index, ...wrapped }); await tap(index, "copy"); expect(await evaluate("navigator.clipboard.readText()")).toBe(snippets[index]); await screenshot(`wrapped-${index}.png`); await toggleWrap(index); await expect.poll(wraps).toEqual(snippets.map(() => "false")); } const feedbackLayout = () => evaluate(`(() => { const card=${blocks}[0]; const button=card.querySelector(${JSON.stringify(MENU)}); const c=card.getBoundingClientRect(), b=button.getBoundingClientRect(); return {height:c.height,buttonWidth:b.width,buttonHeight:b.height,left:b.left-c.left,top:b.top-c.top}; })()`); const beforeFeedback = await feedbackLayout(); // Both ways to copy refuse: the clipboard API, and the legacy execCommand a phone on plain http falls back to. await evaluate(`Object.defineProperty(navigator.clipboard, 'writeText', { configurable:true,value:async()=>{throw new Error('fixture denial');} }); Object.defineProperty(document, 'execCommand', { configurable:true,value:()=>false }); true`); await tap(0, "copy"); await expect.poll(() => evaluate(`${blocks}[0].querySelector('[role="status"]')?.textContent`)).toBe("Could not copy"); expect(await feedbackLayout()).toEqual(beforeFeedback); receipts.failureFeedback = true; await evaluate(`Object.defineProperty(navigator.clipboard, 'writeText', { configurable:true,value:window.fixtureClipboardWrite }); delete document.execCommand; true`); await tap(0, "copy"); await expect.poll(() => evaluate(`${blocks}[0].querySelector('[role="status"]')?.textContent`)).toBe("Copied to clipboard"); expect(await feedbackLayout()).toEqual(beforeFeedback); receipts.successFeedback = true; await expect.poll(() => evaluate(`${blocks}[0].querySelector('[role="status"]')?.textContent ?? null`), { timeout: 5_000 }).toBe(null); expect(await feedbackLayout()).toEqual(beforeFeedback); receipts.feedbackLayoutStable = true; await request("Emulation.setTouchEmulationEnabled", { enabled: false }); await request("Emulation.setDeviceMetricsOverride", { width: 1100, height: 900, deviceScaleFactor: 1, mobile: false }); await evaluate(`${blocks}[0].querySelector(${JSON.stringify(COPY)}).focus(); true`); await request("Input.dispatchKeyEvent", { type:"keyDown", key:"Tab", code:"Tab", windowsVirtualKeyCode:9 }); await request("Input.dispatchKeyEvent", { type:"keyUp", key:"Tab", code:"Tab", windowsVirtualKeyCode:9 }); const focus = await evaluate(`(() => { const el=document.activeElement, style=getComputedStyle(el);return {label:el.getAttribute('aria-label'),visible:el.matches(':focus-visible'),ring:style.outlineStyle!=='none'||style.boxShadow!=='none'}; })()`); expect(focus.label).toBe("More"); expect(focus.visible).toBe(true); expect(focus.ring).toBe(true); receipts.keyboardFocus = focus; receipts.completed = true; save(); console.log(`Mobile code-block evidence: ${evidence}`); } finally { cdp?.close(); if (chrome) await waitForExit(chrome, { signal: "SIGTERM" }); const closed = await Promise.allSettled([preview?.close(), fixture?.close()]); if (evidence) { receipts.cleanup = { chromeExited: !chrome || chrome.exitCode !== null || chrome.signalCode !== null, serverExited: !fixture || fixture.child.exitCode !== null || fixture.child.signalCode !== null, dataRemoved: !fixture || !existsSync(fixture.info.dataDir) }; writeFileSync(join(evidence, "receipt.json"), JSON.stringify(receipts, null, 2)); expect(receipts.cleanup).toEqual({ chromeExited: true, serverExited: true, dataRemoved: true }); } expect(closed.filter(result => result.status === "rejected")).toEqual([]); } }, 360_000);