From e8d2737b8dc788e0fac570bbf7588394900389b0 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sat, 19 Sep 2026 16:37:24 +0100 Subject: [PATCH 1/3] Match canvas backgrounds to the active theme Co-Authored-By: Copse Copse-Models: gpt-6, acp:claude-acp#claude-fable-5-1[1m] --- src/main/index.ts | 34 ++- src/main/services/browser/session-manager.ts | 18 +- src/main/services/canvas-agent-mirror.test.ts | 27 ++- src/main/services/canvas-agent-mirror.ts | 20 +- src/renderer/styles/global/layout.css | 3 + tests/e2e/canvas-background-parity.e2e.ts | 221 ++++++++++++++++++ 6 files changed, 306 insertions(+), 17 deletions(-) create mode 100644 tests/e2e/canvas-background-parity.e2e.ts 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..f04cb33f6b --- /dev/null +++ b/tests/e2e/canvas-background-parity.e2e.ts @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { $, $$, browser } from '@wdio/globals' +import { PNG } from 'pngjs' +import type { MockScriptStep } from '../../src/shared/llm/mock-script.ts' +import { resetUserData, seedEmptyProject } from './helpers/seed-config.ts' +import { waitForAgentIdle, waitForPromptReady } from './helpers.ts' +import { setComposerValue } from './helpers/composer.ts' +import { saveThreePaneScreenshot } from './helpers/screenshot.ts' + +const PROJECT_ID = 'e2e-canvas-background-parity' +const TITLE = 'Transparent canvas parity' +const OVERRIDE_TITLE = 'Explicit canvas background' +const FIXTURE_PATH = join(process.cwd(), '.tmp', 'canvas-transparent-background.html') +const OVERRIDE_FIXTURE_PATH = join(process.cwd(), '.tmp', 'canvas-explicit-background.html') +const REFERENCE = + '\u{e200}visualize\u{e202}' + + JSON.stringify({ path: FIXTURE_PATH, title: TITLE, mode: 'wide' }) + + '\u{e201}' +const OVERRIDE_REFERENCE = + '\u{e200}visualize\u{e202}' + + JSON.stringify({ path: OVERRIDE_FIXTURE_PATH, title: OVERRIDE_TITLE, mode: 'wide' }) + + '\u{e201}' +const SCRIPT = [ + { + when: 'render the transparent canvas', + text: REFERENCE, + }, + { + when: 'render the explicit canvas background', + text: OVERRIDE_REFERENCE, + }, +] satisfies MockScriptStep[] + +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.

+
+ +` + +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) + }) +} + +describe('canvas background parity', () => { + before(async () => { + process.env.COPSE_PANEL_MOCK_LLM = '1' + process.env.ANTHROPIC_API_KEY = '' + process.env.OPENAI_API_KEY = '' + + mkdirSync(dirname(FIXTURE_PATH), { recursive: true }) + writeFileSync(FIXTURE_PATH, TRANSPARENT_ARTEFACT) + writeFileSync(OVERRIDE_FIXTURE_PATH, OVERRIDDEN_ARTEFACT) + resetUserData() + seedEmptyProject(process.cwd(), PROJECT_ID, { + model: 'claude-sonnet-4-6', + 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() + rmSync(FIXTURE_PATH, { force: true }) + rmSync(OVERRIDE_FIXTURE_PATH, { force: true }) + }) + + it('matches a transparent preview to the live dark canvas', async function () { + this.timeout(90_000) + await waitForPromptReady() + await installMockScript() + + await setComposerValue('Please render the transparent canvas.') + await $('.submit-btn').click() + await waitForAgentIdle(45_000) + + const card = $('.message-canvas-previews .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 + }, '.message-canvas-previews .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 comma = preview.indexOf(',') + assert.ok(comma >= 0) + const png = PNG.sync.read(Buffer.from(preview.slice(comma + 1), 'base64')) + const previewCorner = Array.from(png.data.subarray(0, 4)) + const themePixel = await resolvedBodyBackgroundPixel() + assert.deepEqual(previewCorner, 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 browser.execute(() => { + document + .querySelector('.message-canvas-previews .canvas-preview-card') + ?.scrollIntoView({ block: 'center' }) + }) + await saveThreePaneScreenshot('canvas-transparent-background-dark.png', { + filesPaneWidth: 600, + }) + }) + + it('lets an artefact override the default canvas background', async function () { + this.timeout(90_000) + + await setComposerValue('Please render the explicit canvas background.') + await $('.submit-btn').click() + await waitForAgentIdle(45_000) + + const cards = await $$('.message-canvas-previews .canvas-preview-card') + const card = cards.at(-1) + assert.ok(card) + await card.waitForExist({ timeout: 20_000 }) + const image = card.$('.canvas-preview-image') + 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 preview = await image.getAttribute('src') + assert.ok(preview?.startsWith('data:image/png;base64,')) + const comma = preview.indexOf(',') + assert.ok(comma >= 0) + const png = PNG.sync.read(Buffer.from(preview.slice(comma + 1), 'base64')) + assert.deepEqual(Array.from(png.data.subarray(0, 4)), EXPLICIT_BACKGROUND) + }) +}) From 09c7307d7924a56d29887a19a322488aff03cab9 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sun, 20 Sep 2026 00:22:25 +0100 Subject: [PATCH 2/3] Fix adm-zip audit blocker --- patches/adm-zip@0.6.0.patch | 183 ------------------------------------ pnpm-lock.yaml | 13 +-- pnpm-workspace.yaml | 12 +-- 3 files changed, 10 insertions(+), 198 deletions(-) delete mode 100644 patches/adm-zip@0.6.0.patch 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 From eb434cc8bbd13c2f9b05b37baf73d1ef2bbbb88f Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sun, 20 Sep 2026 01:01:05 +0100 Subject: [PATCH 3/3] Exercise canvas background parity through the enabled MCP tool --- tests/e2e/canvas-background-parity.e2e.ts | 149 ++++++++++-------- .../canvas-transparent-background-dark.png | Bin 0 -> 25926 bytes 2 files changed, 86 insertions(+), 63 deletions(-) create mode 100644 tests/e2e/screenshots/canvas-transparent-background-dark.png diff --git a/tests/e2e/canvas-background-parity.e2e.ts b/tests/e2e/canvas-background-parity.e2e.ts index f04cb33f6b..a0038dfba0 100644 --- a/tests/e2e/canvas-background-parity.e2e.ts +++ b/tests/e2e/canvas-background-parity.e2e.ts @@ -1,37 +1,15 @@ import assert from 'node:assert/strict' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' import { $, $$, browser } from '@wdio/globals' -import { PNG } from 'pngjs' -import type { MockScriptStep } from '../../src/shared/llm/mock-script.ts' +import type { MockScriptStep } from '@copse/llm/mock-script' import { resetUserData, seedEmptyProject } from './helpers/seed-config.ts' -import { waitForAgentIdle, waitForPromptReady } from './helpers.ts' +import { waitForPromptReady } from './helpers.ts' import { setComposerValue } from './helpers/composer.ts' -import { saveThreePaneScreenshot } from './helpers/screenshot.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 FIXTURE_PATH = join(process.cwd(), '.tmp', 'canvas-transparent-background.html') -const OVERRIDE_FIXTURE_PATH = join(process.cwd(), '.tmp', 'canvas-explicit-background.html') -const REFERENCE = - '\u{e200}visualize\u{e202}' + - JSON.stringify({ path: FIXTURE_PATH, title: TITLE, mode: 'wide' }) + - '\u{e201}' -const OVERRIDE_REFERENCE = - '\u{e200}visualize\u{e202}' + - JSON.stringify({ path: OVERRIDE_FIXTURE_PATH, title: OVERRIDE_TITLE, mode: 'wide' }) + - '\u{e201}' -const SCRIPT = [ - { - when: 'render the transparent canvas', - text: REFERENCE, - }, - { - when: 'render the explicit canvas background', - text: OVERRIDE_REFERENCE, - }, -] satisfies MockScriptStep[] +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] @@ -75,6 +53,19 @@ const OVERRIDDEN_ARTEFACT = `
` +// 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 = ( @@ -101,18 +92,63 @@ async function resolvedBodyBackgroundPixel(): Promise { }) } +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 = '' - mkdirSync(dirname(FIXTURE_PATH), { recursive: true }) - writeFileSync(FIXTURE_PATH, TRANSPARENT_ARTEFACT) - writeFileSync(OVERRIDE_FIXTURE_PATH, OVERRIDDEN_ARTEFACT) resetUserData() seedEmptyProject(process.cwd(), PROJECT_ID, { model: 'claude-sonnet-4-6', + mcpUiCanvasEnabled: true, theme: 'dark', uiTintColor: '#244c25', uiTintStrength: 'strong', @@ -129,8 +165,6 @@ describe('canvas background parity', () => { ).__copseE2e?.clearMockScript?.() }) resetUserData() - rmSync(FIXTURE_PATH, { force: true }) - rmSync(OVERRIDE_FIXTURE_PATH, { force: true }) }) it('matches a transparent preview to the live dark canvas', async function () { @@ -138,11 +172,9 @@ describe('canvas background parity', () => { await waitForPromptReady() await installMockScript() - await setComposerValue('Please render the transparent canvas.') - await $('.submit-btn').click() - await waitForAgentIdle(45_000) + await renderCanvas('Please render the transparent canvas.', 1) - const card = $('.message-canvas-previews .canvas-preview-card') + const card = $('.canvas-preview-card') await card.waitForExist({ timeout: 20_000 }) const image = card.$('.canvas-preview-image') await browser.waitUntil( @@ -150,18 +182,22 @@ describe('canvas background parity', () => { browser.execute((selector) => { const candidate = document.querySelector(selector) return candidate?.complete === true && candidate.naturalWidth > 0 - }, '.message-canvas-previews .canvas-preview-image'), + }, '.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 comma = preview.indexOf(',') - assert.ok(comma >= 0) - const png = PNG.sync.read(Buffer.from(preview.slice(comma + 1), 'base64')) - const previewCorner = Array.from(png.data.subarray(0, 4)) + const previewCorner = await previewCornerPixel(TITLE) const themePixel = await resolvedBodyBackgroundPixel() - assert.deepEqual(previewCorner, themePixel) + // 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 }) @@ -177,28 +213,14 @@ describe('canvas background parity', () => { }) assert.equal(surfaces.canvas, surfaces.app) - await browser.execute(() => { - document - .querySelector('.message-canvas-previews .canvas-preview-card') - ?.scrollIntoView({ block: 'center' }) - }) - await saveThreePaneScreenshot('canvas-transparent-background-dark.png', { - filesPaneWidth: 600, - }) + 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 setComposerValue('Please render the explicit canvas background.') - await $('.submit-btn').click() - await waitForAgentIdle(45_000) + await renderCanvas('Please render the explicit canvas background.', 2) - const cards = await $$('.message-canvas-previews .canvas-preview-card') - const card = cards.at(-1) - assert.ok(card) - await card.waitForExist({ timeout: 20_000 }) - const image = card.$('.canvas-preview-image') await browser.waitUntil( async () => browser.execute((title) => { @@ -211,11 +233,12 @@ describe('canvas background parity', () => { { 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,')) - const comma = preview.indexOf(',') - assert.ok(comma >= 0) - const png = PNG.sync.read(Buffer.from(preview.slice(comma + 1), 'base64')) - assert.deepEqual(Array.from(png.data.subarray(0, 4)), EXPLICIT_BACKGROUND) + 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 0000000000000000000000000000000000000000..512f12eee3f95289d3db30e5a724c78048fbdba6 GIT binary patch literal 25926 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 literal 0 HcmV?d00001