diff --git a/patches/adm-zip@0.6.0.patch b/patches/adm-zip@0.6.0.patch deleted file mode 100644 index 128cdcffac..0000000000 --- a/patches/adm-zip@0.6.0.patch +++ /dev/null @@ -1,183 +0,0 @@ -diff --git a/util/utils.js b/util/utils.js -index 8b8efaed31d64a4aa456d4b0905fac2599805ffe..35b0b3eaef2c1c792f3949d03f9d7ba813f70ca2 100644 ---- a/util/utils.js -+++ b/util/utils.js -@@ -39,6 +39,10 @@ module.exports = Utils; - Utils.prototype.makeDir = function (/*String*/ folder) { - const self = this; - -+ function lstatSync(path) { -+ return typeof self.fs.lstatSync === "function" ? self.fs.lstatSync(path) : self.fs.statSync(path); -+ } -+ - // Sync - make directories tree - function mkdirSync(/*String*/ fpath) { - let resolvedPath = fpath.split(self.sep)[0]; -@@ -47,7 +51,7 @@ Utils.prototype.makeDir = function (/*String*/ folder) { - resolvedPath += self.sep + name; - var stat; - try { -- stat = self.fs.statSync(resolvedPath); -+ stat = lstatSync(resolvedPath); - } catch (e) { - if (e.message && e.message.startsWith('ENOENT')) { - self.fs.mkdirSync(resolvedPath); -@@ -55,6 +59,7 @@ Utils.prototype.makeDir = function (/*String*/ folder) { - throw e; - } - } -+ if (stat && stat.isSymbolicLink && stat.isSymbolicLink()) throw Errors.FILE_IN_THE_WAY(`"${resolvedPath}"`); - if (stat && stat.isFile()) throw Errors.FILE_IN_THE_WAY(`"${resolvedPath}"`); - }); - } -@@ -64,6 +69,25 @@ Utils.prototype.makeDir = function (/*String*/ folder) { - - Utils.prototype.writeFileTo = function (/*String*/ path, /*Buffer*/ content, /*Boolean*/ overwrite, /*Number*/ attr) { - const self = this; -+ const lstatSync = typeof self.fs.lstatSync === "function" ? self.fs.lstatSync.bind(self.fs) : self.fs.statSync.bind(self.fs); -+ let resolvedPath = pth.parse(path).root; -+ const pathParts = path.slice(resolvedPath.length).split(self.sep); -+ -+ // Refuse every existing symlink component, including the final file. The -+ // extraction containment check is lexical; following one of these links -+ // would let a write escape its destination without any `..` in the ZIP. -+ for (const part of pathParts) { -+ if (!part) continue; -+ resolvedPath = pth.join(resolvedPath, part); -+ try { -+ const stat = lstatSync(resolvedPath); -+ if (stat.isSymbolicLink && stat.isSymbolicLink()) throw Errors.FILE_IN_THE_WAY(`"${resolvedPath}"`); -+ } catch (e) { -+ if (e.code === "ENOENT" || (e.message && e.message.startsWith("ENOENT"))) break; -+ throw e; -+ } -+ } -+ - if (self.fs.existsSync(path)) { - if (!overwrite) return false; // cannot overwrite - -@@ -103,56 +127,81 @@ Utils.prototype.writeFileToAsync = function (/*String*/ path, /*Buffer*/ content - - const self = this; - -- self.fs.exists(path, function (exist) { -- if (exist && !overwrite) return callback(false); -+ const checkSymlink = function (done) { -+ if (typeof self.fs.lstat !== "function") return done(true); -+ -+ let resolvedPath = pth.parse(path).root; -+ const pathParts = path.slice(resolvedPath.length).split(self.sep); -+ const checkNext = function (index) { -+ if (index === pathParts.length) return done(true); -+ const part = pathParts[index]; -+ if (!part) return checkNext(index + 1); -+ resolvedPath = pth.join(resolvedPath, part); -+ self.fs.lstat(resolvedPath, function (err, stat) { -+ if (err) { -+ if (err.code === "ENOENT" || (err.message && err.message.startsWith("ENOENT"))) return done(true); -+ return done(false); -+ } -+ if (stat.isSymbolicLink && stat.isSymbolicLink()) return done(false); -+ checkNext(index + 1); -+ }); -+ }; -+ checkNext(0); -+ }; - -- self.fs.stat(path, function (err, stat) { -- if (exist && stat && stat.isDirectory()) { -- return callback(false); -- } -+ checkSymlink(function (safe) { -+ if (!safe) return callback(false); -+ self.fs.exists(path, function (exist) { -+ if (exist && !overwrite) return callback(false); - -- var folder = pth.dirname(path); -- self.fs.exists(folder, function (exists) { -- if (!exists) { -- // makeDir is synchronous and can throw (e.g. EACCES); report failure -- // rather than letting it escape this callback as an uncaught exception -- try { -- self.makeDir(folder); -- } catch (e) { -- return callback(false); -- } -+ self.fs.stat(path, function (err, stat) { -+ if (exist && stat && stat.isDirectory()) { -+ return callback(false); - } - -- // write the content to an open descriptor, then apply the attributes -- const writeToFd = function (fd) { -- self.fs.write(fd, content, 0, content.length, 0, function (writeErr) { -- self.fs.close(fd, function () { -- // surface write failures instead of silently reporting success (issue #402) -- if (writeErr) return callback(false); -- self.fs.chmod(path, attr || 0o666, function () { -- callback(true); -+ var folder = pth.dirname(path); -+ self.fs.exists(folder, function (exists) { -+ if (!exists) { -+ // makeDir is synchronous and can throw (e.g. EACCES); report failure -+ // rather than letting it escape this callback as an uncaught exception -+ try { -+ self.makeDir(folder); -+ } catch (e) { -+ return callback(false); -+ } -+ } -+ -+ // write the content to an open descriptor, then apply the attributes -+ const writeToFd = function (fd) { -+ self.fs.write(fd, content, 0, content.length, 0, function (writeErr) { -+ self.fs.close(fd, function () { -+ // surface write failures instead of silently reporting success (issue #402) -+ if (writeErr) return callback(false); -+ self.fs.chmod(path, attr || 0o666, function () { -+ callback(true); -+ }); - }); - }); -- }); -- }; -- -- self.fs.open(path, "w", 0o666, function (err, fd) { -- if (err) { -- // the target may exist but be read-only: make it writable and retry once -- self.fs.chmod(path, 0o666, function () { -- self.fs.open(path, "w", 0o666, function (retryErr, fd) { -- // Previously the retry error was ignored and an undefined fd was -- // passed to fs.write, throwing an uncaught ERR_INVALID_ARG_TYPE that -- // crashed the process (issues #470, #459, #402). Report failure instead. -- if (retryErr || !fd) return callback(false); -- writeToFd(fd); -+ }; -+ -+ self.fs.open(path, "w", 0o666, function (err, fd) { -+ if (err) { -+ // the target may exist but be read-only: make it writable and retry once -+ self.fs.chmod(path, 0o666, function () { -+ self.fs.open(path, "w", 0o666, function (retryErr, fd) { -+ // Previously the retry error was ignored and an undefined fd was -+ // passed to fs.write, throwing an uncaught ERR_INVALID_ARG_TYPE that -+ // crashed the process (issues #470, #459, #402). Report failure instead. -+ if (retryErr || !fd) return callback(false); -+ writeToFd(fd); -+ }); - }); -- }); -- } else if (fd) { -- writeToFd(fd); -- } else { -- callback(false); -- } -+ } else if (fd) { -+ writeToFd(fd); -+ } else { -+ callback(false); -+ } -+ }); - }); - }); - }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56d806b187..4fb8e2948c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: '@hono/node-server': 2.0.11 '@xmldom/xmldom@0.8': 0.8.15 - adm-zip: ^0.6.0 + adm-zip: ^0.6.1 brace-expansion: 5.0.9 minimatch: 10.2.5 deepmerge-ts: 8.0.0 @@ -28,9 +28,6 @@ patchedDependencies: '@anthropic-ai/sandbox-runtime@0.0.74': hash: 3af40e346c5cb771abf30a0a0b6cf2a64e2de5500b20fab9f500f21089d916c8 path: patches/@anthropic-ai__sandbox-runtime@0.0.74.patch - adm-zip@0.6.0: - hash: b7a4d985cd2a95694e3292c0ee538dd780db7ef714b42bb7d3eab8eb4c9ff43f - path: patches/adm-zip@0.6.0.patch importers: @@ -2076,8 +2073,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adm-zip@0.6.0: - resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} + adm-zip@0.6.1: + resolution: {integrity: sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==} engines: {node: '>=14.0'} agent-base@7.1.4: @@ -6741,7 +6738,7 @@ snapshots: acorn@8.18.0: {} - adm-zip@0.6.0(patch_hash=b7a4d985cd2a95694e3292c0ee538dd780db7ef714b42bb7d3eab8eb4c9ff43f): + adm-zip@0.6.1: optional: true agent-base@7.1.4: {} @@ -8935,7 +8932,7 @@ snapshots: onnxruntime-node@1.24.3: dependencies: - adm-zip: 0.6.0(patch_hash=b7a4d985cd2a95694e3292c0ee538dd780db7ef714b42bb7d3eab8eb4c9ff43f) + adm-zip: 0.6.1 global-agent: 3.0.0 onnxruntime-common: 1.24.3 optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 13798476e1..decf8fa81d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,7 +29,7 @@ supportedArchitectures: overrides: '@hono/node-server': 2.0.11 '@xmldom/xmldom@0.8': 0.8.15 - adm-zip: ^0.6.0 + adm-zip: ^0.6.1 brace-expansion: 5.0.9 minimatch: 10.2.5 deepmerge-ts: 8.0.0 @@ -73,11 +73,10 @@ minimumReleaseAgeExclude: - ip-address - undici -# adm-zip has no non-vulnerable published release: 0.6.0 fixes the 4 GB memory -# allocation advisory, but it and every release back to 0.5.9 follow destination -# symlinks during extraction. Keep the upstream-compatible symlink guard locally -# until a release newer than 0.6.0 includes cthackers/adm-zip#575. The regression -# test in scripts/adm-zip-symlink-patch.test.ts fails if this patch is dropped. +# adm-zip 0.6.1 fixes the declared-size allocation advisory and includes the +# destination-symlink protections previously carried as a local patch. The +# regression test in scripts/adm-zip-symlink-patch.test.ts keeps that protection +# pinned if a future release regresses it. # Four patches to @anthropic-ai/sandbox-runtime, all in one patch file. Drop # each once upstream does the same; the named test fails loudly if a version @@ -112,4 +111,3 @@ minimumReleaseAgeExclude: # `project-sandbox/worktree-preparation.test.ts`. patchedDependencies: '@anthropic-ai/sandbox-runtime@0.0.74': patches/@anthropic-ai__sandbox-runtime@0.0.74.patch - adm-zip@0.6.0: patches/adm-zip@0.6.0.patch diff --git a/src/main/index.ts b/src/main/index.ts index b7fd26762c..af1127eaa9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,6 +29,7 @@ import { import { attachBrowserGuestContextMenu } from './windows/browser-context-menu.ts' import { applyAppIcon } from './app-icon.ts' import type { LLMMessage, StreamChunk } from '@shared/types' +import { THEME_BACKGROUND } from '@shared/theme.ts' import { assertPrimaryMainWindow, beginMainWindowQuit, @@ -37,6 +38,7 @@ import { getMainWindow, getRestorableMainWindowRecords, } from './windows/create-main-window.ts' +import { readBootTheme } from './windows/boot-theme.ts' import { setShellOutputSink } from './services/exec/shell-output-context.ts' import { setSecretCipher } from './services/storage/secret-cipher.ts' import { createKeyringCipher, createMigratingCipher } from './services/storage/keyring-cipher.ts' @@ -301,9 +303,39 @@ setCanvasArtefactSink((artefact) => { }) }) +async function currentCanvasBackgroundColor(): Promise { + const fallback = THEME_BACKGROUND[readBootTheme()] + const win = getMainWindow() + if (!win || win.isDestroyed()) return fallback + try { + const value: unknown = await win.webContents.executeJavaScript( + `(() => { + const canvas = document.createElement('canvas') + canvas.width = 1 + canvas.height = 1 + const context = canvas.getContext('2d') + if (!context) return '' + context.fillStyle = getComputedStyle(document.body).backgroundColor + context.fillRect(0, 0, 1, 1) + const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data + if (alpha === 0) return '' + return 'rgba(' + [red, green, blue, alpha / 255].join(', ') + ')' + })()`, + true, + ) + return typeof value === 'string' && value.trim() ? value : fallback + } catch { + return fallback + } +} + // Load every artefact into the headless agent session as well, so the model can // snapshot and screenshot the canvas it just rendered instead of working blind. -setCanvasArtefactMirror((artefact) => mirrorArtefactToAgent(artefact, getBrowserSession())) +// The preview window is otherwise white by default, while the visible webview +// exposes Copse's theme through a transparent artefact. +setCanvasArtefactMirror(async (artefact) => + mirrorArtefactToAgent(artefact, getBrowserSession(), await currentCanvasBackgroundColor()), +) setContextEstimateRefreshSink(() => { const win = getMainWindow() diff --git a/src/main/services/browser/session-manager.ts b/src/main/services/browser/session-manager.ts index d81d7a150c..b2a7eeaef2 100644 --- a/src/main/services/browser/session-manager.ts +++ b/src/main/services/browser/session-manager.ts @@ -47,6 +47,13 @@ export interface NavigateResult { url: string } +export interface BrowserNavigateOptions { + newTab?: boolean | undefined + viewId?: string | undefined + /** Backdrop used when a page leaves its root transparent. */ + backgroundColor?: string | undefined +} + export interface TabInfo { viewId: string title: string @@ -69,7 +76,7 @@ export class BrowserSessionManager { private lastActiveId: string | null = null private counter = 0 - private createTab(): Tab { + private createTab(backgroundColor?: string): Tab { if (this.tabs.length >= MAX_TABS) { throw new Error(`browser tab limit reached (${String(MAX_TABS)}); close a tab first`) } @@ -79,6 +86,7 @@ export class BrowserSessionManager { show: false, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, + ...(backgroundColor ? { backgroundColor } : {}), webPreferences: { // Dedicated agent browser profile, isolated from the user's interactive // browser pane, so automation never inherits the user's logged-in @@ -116,12 +124,10 @@ export class BrowserSessionManager { return this.createTab() } - async navigate( - url: string, - opts?: { newTab?: boolean | undefined; viewId?: string | undefined }, - ): Promise { - const tab = opts?.newTab ? this.createTab() : this.resolveTab(opts?.viewId) + async navigate(url: string, opts?: BrowserNavigateOptions): Promise { + const tab = opts?.newTab ? this.createTab(opts.backgroundColor) : this.resolveTab(opts?.viewId) this.lastActiveId = tab.id + if (opts?.backgroundColor) tab.window.setBackgroundColor(opts.backgroundColor) try { await tab.window.webContents.loadURL(url) } catch (err) { diff --git a/src/main/services/canvas-agent-mirror.test.ts b/src/main/services/canvas-agent-mirror.test.ts index 370b5dd394..c792b72be5 100644 --- a/src/main/services/canvas-agent-mirror.test.ts +++ b/src/main/services/canvas-agent-mirror.test.ts @@ -10,7 +10,13 @@ function artefact(overrides: Partial = {}): CanvasArtefact { interface Call { url: string - opts?: { newTab?: boolean | undefined; viewId?: string | undefined } | undefined + opts?: + | { + newTab?: boolean | undefined + viewId?: string | undefined + backgroundColor?: string | undefined + } + | undefined } function session( @@ -45,15 +51,28 @@ describe('mirrorArtefactToAgent', () => { assert.match(s.calls[0].url, /^data:text\/html;charset=utf-8;base64,/) }) + it('uses the live canvas background when capturing transparent artefacts', async () => { + const s = session() + await mirrorArtefactToAgent(artefact(), s, 'rgb(17, 29, 23)') + + assert.deepEqual(s.calls[0]?.opts, { + newTab: true, + backgroundColor: 'rgb(17, 29, 23)', + }) + }) + it('reuses the tab for a re-render of the same title', async () => { const s = session() - await mirrorArtefactToAgent(artefact(), s) - await mirrorArtefactToAgent(artefact({ body: '

v2

' }), s) + await mirrorArtefactToAgent(artefact(), s, 'rgb(17, 29, 23)') + await mirrorArtefactToAgent(artefact({ body: '

v2

' }), s, 'rgb(17, 29, 23)') assert.equal(s.calls.length, 2) const [v1, v2] = s.calls assert.ok(v1 && v2) - assert.deepEqual(v2.opts, { viewId: 'tab-1' }) + assert.deepEqual(v2.opts, { + viewId: 'tab-1', + backgroundColor: 'rgb(17, 29, 23)', + }) assert.notEqual(v1.url, v2.url) }) diff --git a/src/main/services/canvas-agent-mirror.ts b/src/main/services/canvas-agent-mirror.ts index b7f0be06b1..e52a3c50f9 100644 --- a/src/main/services/canvas-agent-mirror.ts +++ b/src/main/services/canvas-agent-mirror.ts @@ -19,11 +19,14 @@ import type { CanvasArtefact } from '@shared/types/canvas.ts' import { artefactUrl } from '@shared/canvas/artefact.ts' +interface CanvasMirrorNavigateOptions { + newTab?: boolean | undefined + viewId?: string | undefined + backgroundColor?: string | undefined +} + export interface CanvasMirrorSession { - navigate( - url: string, - opts?: { newTab?: boolean | undefined; viewId?: string | undefined }, - ): Promise<{ viewId: string }> + navigate(url: string, opts?: CanvasMirrorNavigateOptions): Promise<{ viewId: string }> /** A small PNG `data:` URL of the tab, or null when capture is unavailable. */ capturePreview(viewId: string): Promise } @@ -54,6 +57,7 @@ export function resetCanvasAgentMirrorForTest(): void { export async function mirrorArtefactToAgent( artefact: CanvasArtefact, session: CanvasMirrorSession, + backgroundColor?: string, ): Promise { // `text/uri-list` is supplied by an MCP server and may name any external // origin. Navigating it here would bypass the approval that guards @@ -69,10 +73,12 @@ export async function mirrorArtefactToAgent( } const key = artefactKey(artefact.threadId, artefact.title) const known = viewIdByArtefact.get(key) + const navigation: CanvasMirrorNavigateOptions = known ? { viewId: known } : { newTab: true } + if (backgroundColor) navigation.backgroundColor = backgroundColor let viewId: string try { - viewId = (await session.navigate(url, known ? { viewId: known } : { newTab: true })).viewId + viewId = (await session.navigate(url, navigation)).viewId } catch { // The remembered tab is gone — the agent closed it via `browser_tabs`, or // the session was torn down. Forget it and try once more in a fresh tab. @@ -81,7 +87,9 @@ export async function mirrorArtefactToAgent( if (!known) return null viewIdByArtefact.delete(key) try { - viewId = (await session.navigate(url, { newTab: true })).viewId + const retry: CanvasMirrorNavigateOptions = { newTab: true } + if (backgroundColor) retry.backgroundColor = backgroundColor + viewId = (await session.navigate(url, retry)).viewId } catch { return null } diff --git a/src/renderer/styles/global/layout.css b/src/renderer/styles/global/layout.css index afc68863c4..e8d75e2c76 100644 --- a/src/renderer/styles/global/layout.css +++ b/src/renderer/styles/global/layout.css @@ -2052,6 +2052,9 @@ flex-direction: column; min-height: 0; overflow: hidden; + /* Transparent canvas artefacts expose this surface. The headless mirror + captures against the resolved value of the same token. */ + background: var(--bg-base); -webkit-app-region: no-drag; position: relative; } diff --git a/tests/e2e/canvas-background-parity.e2e.ts b/tests/e2e/canvas-background-parity.e2e.ts new file mode 100644 index 0000000000..a0038dfba0 --- /dev/null +++ b/tests/e2e/canvas-background-parity.e2e.ts @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict' +import { $, $$, browser } from '@wdio/globals' +import type { MockScriptStep } from '@copse/llm/mock-script' +import { resetUserData, seedEmptyProject } from './helpers/seed-config.ts' +import { waitForPromptReady } from './helpers.ts' +import { setComposerValue } from './helpers/composer.ts' +import { saveElementScreenshot } from './helpers/screenshot.ts' + +const PROJECT_ID = 'e2e-canvas-background-parity' +const TITLE = 'Transparent Canvas Parity' +const OVERRIDE_TITLE = 'Explicit Canvas Background' +const CANVAS_TOOL = 'mcp__copse-canvas__render_html_artefact' + +const EXPLICIT_BACKGROUND = [226, 166, 58, 255] + +const TRANSPARENT_ARTEFACT = `
+

Theme-backed canvas

+

The document is transparent, so Copse supplies this surface.

+
+ +` + +const OVERRIDDEN_ARTEFACT = `
+

Artifact-owned canvas

+

This document explicitly overrides Copse's default surface.

+
+ +` + +// The local mock provider renders through MCP; inline visualization control +// frames are handled by the ACP executor only. +const SCRIPT = [ + { + when: 'render the transparent canvas', + tool: { name: CANVAS_TOOL, args: { title: TITLE, html: TRANSPARENT_ARTEFACT } }, + }, + { + when: 'render the explicit canvas background', + tool: { name: CANVAS_TOOL, args: { title: OVERRIDE_TITLE, html: OVERRIDDEN_ARTEFACT } }, + }, +] satisfies MockScriptStep[] + +async function installMockScript(): Promise { + const status = await browser.execute(async (script) => { + const bridge = ( + window as unknown as { + __copseE2e?: { setMockScript: (value: unknown) => Promise<{ steps: number }> } + } + ).__copseE2e + if (!bridge?.setMockScript) throw new Error('__copseE2e.setMockScript unavailable') + return bridge.setMockScript(script) + }, SCRIPT) + assert.equal(status.steps, SCRIPT.length) +} + +async function resolvedBodyBackgroundPixel(): Promise { + return browser.execute(() => { + const canvas = document.createElement('canvas') + canvas.width = 1 + canvas.height = 1 + const context = canvas.getContext('2d') + if (!context) throw new Error('2D canvas context unavailable') + context.fillStyle = getComputedStyle(document.body).backgroundColor + context.fillRect(0, 0, 1, 1) + return Array.from(context.getImageData(0, 0, 1, 1).data) + }) +} + +async function previewCornerPixel(title: string): Promise { + return browser.execute((expectedTitle) => { + const card = Array.from(document.querySelectorAll('.canvas-preview-card')).find( + (candidate) => + candidate.querySelector('.canvas-preview-title')?.textContent === expectedTitle, + ) + const image = card?.querySelector('.canvas-preview-image') + if (!image?.complete || !image.naturalWidth) throw new Error('canvas preview is not ready') + const canvas = document.createElement('canvas') + canvas.width = 1 + canvas.height = 1 + const context = canvas.getContext('2d') + if (!context) throw new Error('2D canvas context unavailable') + // Honor the capture's embedded display profile (e.g. Display P3 on macOS) + // before comparing its pixel with the CSS color resolved in sRGB. + context.drawImage(image, 0, 0, 1, 1, 0, 0, 1, 1) + return Array.from(context.getImageData(0, 0, 1, 1).data) + }, title) +} + +async function renderCanvas(prompt: string, expectedToolCount: number): Promise { + await setComposerValue(prompt) + await $('.submit-btn').click() + await browser.waitUntil( + async () => + browser.execute( + (count) => + !document.querySelector('.submit-btn')?.classList.contains('with-stop') && + document.querySelectorAll('.tool-card[data-tool-id][data-status="done"]').length === + count, + expectedToolCount, + ), + { timeout: 30_000, timeoutMsg: 'expected the canvas render tool to finish' }, + ) + // MCP previews are built lazily inside the completed tool's disclosure. + await browser.execute(() => { + for (const rollup of document.querySelectorAll('.tool-card-rollup')) { + if (!rollup.open) rollup.querySelector('summary')?.click() + } + }) + await browser.execute(() => { + for (const tool of document.querySelectorAll('.tool-card[data-tool-id]')) { + if (!tool.open) tool.querySelector('summary')?.click() + } + }) +} + +describe('canvas background parity', () => { + before(async () => { + process.env.COPSE_PANEL_MOCK_LLM = '1' + process.env.ANTHROPIC_API_KEY = '' + process.env.OPENAI_API_KEY = '' + + resetUserData() + seedEmptyProject(process.cwd(), PROJECT_ID, { + model: 'claude-sonnet-4-6', + mcpUiCanvasEnabled: true, + theme: 'dark', + uiTintColor: '#244c25', + uiTintStrength: 'strong', + autoPortraitRightPanel: false, + rightPanelPosition: 'side', + }) + await browser.reloadSession() + }) + + after(async () => { + await browser.execute(async () => { + await ( + window as unknown as { __copseE2e?: { clearMockScript: () => Promise } } + ).__copseE2e?.clearMockScript?.() + }) + resetUserData() + }) + + it('matches a transparent preview to the live dark canvas', async function () { + this.timeout(90_000) + await waitForPromptReady() + await installMockScript() + + await renderCanvas('Please render the transparent canvas.', 1) + + const card = $('.canvas-preview-card') + await card.waitForExist({ timeout: 20_000 }) + const image = card.$('.canvas-preview-image') + await browser.waitUntil( + async () => + browser.execute((selector) => { + const candidate = document.querySelector(selector) + return candidate?.complete === true && candidate.naturalWidth > 0 + }, '.canvas-preview-image'), + { timeout: 20_000, timeoutMsg: 'expected canvas preview image to load' }, + ) + + const preview = await image.getAttribute('src') + assert.ok(preview?.startsWith('data:image/png;base64,')) + const previewCorner = await previewCornerPixel(TITLE) + const themePixel = await resolvedBodyBackgroundPixel() + // An 8-bit display-profile round trip can round an RGB channel by one. + assert.equal(previewCorner.length, themePixel.length) + for (const [channel, expected] of themePixel.entries()) { + assert.ok( + Math.abs((previewCorner[channel] ?? -255) - expected) <= (channel === 3 ? 0 : 1), + `preview ${JSON.stringify(previewCorner)} should match theme ${JSON.stringify(themePixel)}`, + ) + } + + await card.$('button').click() + await $('.browser-tab-panel.is-active .browser-webview').waitForExist({ timeout: 20_000 }) + const surfaces = await browser.execute(() => { + const host = document.querySelector( + '.browser-tab-panel.is-active .browser-webview-host', + ) + if (!host) throw new Error('active browser webview host missing') + return { + app: getComputedStyle(document.body).backgroundColor, + canvas: getComputedStyle(host).backgroundColor, + } + }) + assert.equal(surfaces.canvas, surfaces.app) + + await saveElementScreenshot('.canvas-preview-card', 'canvas-transparent-background-dark.png') + }) + + it('lets an artefact override the default canvas background', async function () { + this.timeout(90_000) + + await renderCanvas('Please render the explicit canvas background.', 2) + + await browser.waitUntil( + async () => + browser.execute((title) => { + const candidate = Array.from( + document.querySelectorAll('.canvas-preview-card'), + ).find((element) => element.textContent?.includes(title)) + const preview = candidate?.querySelector('.canvas-preview-image') + return preview?.complete === true && preview.naturalWidth > 0 + }, OVERRIDE_TITLE), + { timeout: 20_000, timeoutMsg: 'expected explicit canvas preview image to load' }, + ) + + const cards = await $$('.canvas-preview-card') + const card = cards.at(-1) + assert.ok(card) + const image = card.$('.canvas-preview-image') + const preview = await image.getAttribute('src') + assert.ok(preview?.startsWith('data:image/png;base64,')) + assert.deepEqual(await previewCornerPixel(OVERRIDE_TITLE), EXPLICIT_BACKGROUND) + }) +}) diff --git a/tests/e2e/screenshots/canvas-transparent-background-dark.png b/tests/e2e/screenshots/canvas-transparent-background-dark.png new file mode 100644 index 0000000000..512f12eee3 Binary files /dev/null and b/tests/e2e/screenshots/canvas-transparent-background-dark.png differ