Skip to content

Commit eb434cc

Browse files
Jonathan KingstonJonathan Kingston
authored andcommitted
Exercise canvas background parity through the enabled MCP tool
1 parent 09c7307 commit eb434cc

2 files changed

Lines changed: 86 additions & 63 deletions

File tree

tests/e2e/canvas-background-parity.e2e.ts

Lines changed: 86 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,15 @@
11
import assert from 'node:assert/strict'
2-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
3-
import { dirname, join } from 'node:path'
42
import { $, $$, browser } from '@wdio/globals'
5-
import { PNG } from 'pngjs'
6-
import type { MockScriptStep } from '../../src/shared/llm/mock-script.ts'
3+
import type { MockScriptStep } from '@copse/llm/mock-script'
74
import { resetUserData, seedEmptyProject } from './helpers/seed-config.ts'
8-
import { waitForAgentIdle, waitForPromptReady } from './helpers.ts'
5+
import { waitForPromptReady } from './helpers.ts'
96
import { setComposerValue } from './helpers/composer.ts'
10-
import { saveThreePaneScreenshot } from './helpers/screenshot.ts'
7+
import { saveElementScreenshot } from './helpers/screenshot.ts'
118

129
const PROJECT_ID = 'e2e-canvas-background-parity'
13-
const TITLE = 'Transparent canvas parity'
14-
const OVERRIDE_TITLE = 'Explicit canvas background'
15-
const FIXTURE_PATH = join(process.cwd(), '.tmp', 'canvas-transparent-background.html')
16-
const OVERRIDE_FIXTURE_PATH = join(process.cwd(), '.tmp', 'canvas-explicit-background.html')
17-
const REFERENCE =
18-
'\u{e200}visualize\u{e202}' +
19-
JSON.stringify({ path: FIXTURE_PATH, title: TITLE, mode: 'wide' }) +
20-
'\u{e201}'
21-
const OVERRIDE_REFERENCE =
22-
'\u{e200}visualize\u{e202}' +
23-
JSON.stringify({ path: OVERRIDE_FIXTURE_PATH, title: OVERRIDE_TITLE, mode: 'wide' }) +
24-
'\u{e201}'
25-
const SCRIPT = [
26-
{
27-
when: 'render the transparent canvas',
28-
text: REFERENCE,
29-
},
30-
{
31-
when: 'render the explicit canvas background',
32-
text: OVERRIDE_REFERENCE,
33-
},
34-
] satisfies MockScriptStep[]
10+
const TITLE = 'Transparent Canvas Parity'
11+
const OVERRIDE_TITLE = 'Explicit Canvas Background'
12+
const CANVAS_TOOL = 'mcp__copse-canvas__render_html_artefact'
3513

3614
const EXPLICIT_BACKGROUND = [226, 166, 58, 255]
3715

@@ -75,6 +53,19 @@ const OVERRIDDEN_ARTEFACT = `<div id="canvas-background-probe">
7553
</style>
7654
`
7755

56+
// The local mock provider renders through MCP; inline visualization control
57+
// frames are handled by the ACP executor only.
58+
const SCRIPT = [
59+
{
60+
when: 'render the transparent canvas',
61+
tool: { name: CANVAS_TOOL, args: { title: TITLE, html: TRANSPARENT_ARTEFACT } },
62+
},
63+
{
64+
when: 'render the explicit canvas background',
65+
tool: { name: CANVAS_TOOL, args: { title: OVERRIDE_TITLE, html: OVERRIDDEN_ARTEFACT } },
66+
},
67+
] satisfies MockScriptStep[]
68+
7869
async function installMockScript(): Promise<void> {
7970
const status = await browser.execute(async (script) => {
8071
const bridge = (
@@ -101,18 +92,63 @@ async function resolvedBodyBackgroundPixel(): Promise<number[]> {
10192
})
10293
}
10394

95+
async function previewCornerPixel(title: string): Promise<number[]> {
96+
return browser.execute((expectedTitle) => {
97+
const card = Array.from(document.querySelectorAll('.canvas-preview-card')).find(
98+
(candidate) =>
99+
candidate.querySelector('.canvas-preview-title')?.textContent === expectedTitle,
100+
)
101+
const image = card?.querySelector<HTMLImageElement>('.canvas-preview-image')
102+
if (!image?.complete || !image.naturalWidth) throw new Error('canvas preview is not ready')
103+
const canvas = document.createElement('canvas')
104+
canvas.width = 1
105+
canvas.height = 1
106+
const context = canvas.getContext('2d')
107+
if (!context) throw new Error('2D canvas context unavailable')
108+
// Honor the capture's embedded display profile (e.g. Display P3 on macOS)
109+
// before comparing its pixel with the CSS color resolved in sRGB.
110+
context.drawImage(image, 0, 0, 1, 1, 0, 0, 1, 1)
111+
return Array.from(context.getImageData(0, 0, 1, 1).data)
112+
}, title)
113+
}
114+
115+
async function renderCanvas(prompt: string, expectedToolCount: number): Promise<void> {
116+
await setComposerValue(prompt)
117+
await $('.submit-btn').click()
118+
await browser.waitUntil(
119+
async () =>
120+
browser.execute(
121+
(count) =>
122+
!document.querySelector('.submit-btn')?.classList.contains('with-stop') &&
123+
document.querySelectorAll('.tool-card[data-tool-id][data-status="done"]').length ===
124+
count,
125+
expectedToolCount,
126+
),
127+
{ timeout: 30_000, timeoutMsg: 'expected the canvas render tool to finish' },
128+
)
129+
// MCP previews are built lazily inside the completed tool's disclosure.
130+
await browser.execute(() => {
131+
for (const rollup of document.querySelectorAll<HTMLDetailsElement>('.tool-card-rollup')) {
132+
if (!rollup.open) rollup.querySelector('summary')?.click()
133+
}
134+
})
135+
await browser.execute(() => {
136+
for (const tool of document.querySelectorAll<HTMLDetailsElement>('.tool-card[data-tool-id]')) {
137+
if (!tool.open) tool.querySelector('summary')?.click()
138+
}
139+
})
140+
}
141+
104142
describe('canvas background parity', () => {
105143
before(async () => {
106144
process.env.COPSE_PANEL_MOCK_LLM = '1'
107145
process.env.ANTHROPIC_API_KEY = ''
108146
process.env.OPENAI_API_KEY = ''
109147

110-
mkdirSync(dirname(FIXTURE_PATH), { recursive: true })
111-
writeFileSync(FIXTURE_PATH, TRANSPARENT_ARTEFACT)
112-
writeFileSync(OVERRIDE_FIXTURE_PATH, OVERRIDDEN_ARTEFACT)
113148
resetUserData()
114149
seedEmptyProject(process.cwd(), PROJECT_ID, {
115150
model: 'claude-sonnet-4-6',
151+
mcpUiCanvasEnabled: true,
116152
theme: 'dark',
117153
uiTintColor: '#244c25',
118154
uiTintStrength: 'strong',
@@ -129,39 +165,39 @@ describe('canvas background parity', () => {
129165
).__copseE2e?.clearMockScript?.()
130166
})
131167
resetUserData()
132-
rmSync(FIXTURE_PATH, { force: true })
133-
rmSync(OVERRIDE_FIXTURE_PATH, { force: true })
134168
})
135169

136170
it('matches a transparent preview to the live dark canvas', async function () {
137171
this.timeout(90_000)
138172
await waitForPromptReady()
139173
await installMockScript()
140174

141-
await setComposerValue('Please render the transparent canvas.')
142-
await $('.submit-btn').click()
143-
await waitForAgentIdle(45_000)
175+
await renderCanvas('Please render the transparent canvas.', 1)
144176

145-
const card = $('.message-canvas-previews .canvas-preview-card')
177+
const card = $('.canvas-preview-card')
146178
await card.waitForExist({ timeout: 20_000 })
147179
const image = card.$('.canvas-preview-image')
148180
await browser.waitUntil(
149181
async () =>
150182
browser.execute((selector) => {
151183
const candidate = document.querySelector<HTMLImageElement>(selector)
152184
return candidate?.complete === true && candidate.naturalWidth > 0
153-
}, '.message-canvas-previews .canvas-preview-image'),
185+
}, '.canvas-preview-image'),
154186
{ timeout: 20_000, timeoutMsg: 'expected canvas preview image to load' },
155187
)
156188

157189
const preview = await image.getAttribute('src')
158190
assert.ok(preview?.startsWith('data:image/png;base64,'))
159-
const comma = preview.indexOf(',')
160-
assert.ok(comma >= 0)
161-
const png = PNG.sync.read(Buffer.from(preview.slice(comma + 1), 'base64'))
162-
const previewCorner = Array.from(png.data.subarray(0, 4))
191+
const previewCorner = await previewCornerPixel(TITLE)
163192
const themePixel = await resolvedBodyBackgroundPixel()
164-
assert.deepEqual(previewCorner, themePixel)
193+
// An 8-bit display-profile round trip can round an RGB channel by one.
194+
assert.equal(previewCorner.length, themePixel.length)
195+
for (const [channel, expected] of themePixel.entries()) {
196+
assert.ok(
197+
Math.abs((previewCorner[channel] ?? -255) - expected) <= (channel === 3 ? 0 : 1),
198+
`preview ${JSON.stringify(previewCorner)} should match theme ${JSON.stringify(themePixel)}`,
199+
)
200+
}
165201

166202
await card.$('button').click()
167203
await $('.browser-tab-panel.is-active .browser-webview').waitForExist({ timeout: 20_000 })
@@ -177,28 +213,14 @@ describe('canvas background parity', () => {
177213
})
178214
assert.equal(surfaces.canvas, surfaces.app)
179215

180-
await browser.execute(() => {
181-
document
182-
.querySelector('.message-canvas-previews .canvas-preview-card')
183-
?.scrollIntoView({ block: 'center' })
184-
})
185-
await saveThreePaneScreenshot('canvas-transparent-background-dark.png', {
186-
filesPaneWidth: 600,
187-
})
216+
await saveElementScreenshot('.canvas-preview-card', 'canvas-transparent-background-dark.png')
188217
})
189218

190219
it('lets an artefact override the default canvas background', async function () {
191220
this.timeout(90_000)
192221

193-
await setComposerValue('Please render the explicit canvas background.')
194-
await $('.submit-btn').click()
195-
await waitForAgentIdle(45_000)
222+
await renderCanvas('Please render the explicit canvas background.', 2)
196223

197-
const cards = await $$('.message-canvas-previews .canvas-preview-card')
198-
const card = cards.at(-1)
199-
assert.ok(card)
200-
await card.waitForExist({ timeout: 20_000 })
201-
const image = card.$('.canvas-preview-image')
202224
await browser.waitUntil(
203225
async () =>
204226
browser.execute((title) => {
@@ -211,11 +233,12 @@ describe('canvas background parity', () => {
211233
{ timeout: 20_000, timeoutMsg: 'expected explicit canvas preview image to load' },
212234
)
213235

236+
const cards = await $$('.canvas-preview-card')
237+
const card = cards.at(-1)
238+
assert.ok(card)
239+
const image = card.$('.canvas-preview-image')
214240
const preview = await image.getAttribute('src')
215241
assert.ok(preview?.startsWith('data:image/png;base64,'))
216-
const comma = preview.indexOf(',')
217-
assert.ok(comma >= 0)
218-
const png = PNG.sync.read(Buffer.from(preview.slice(comma + 1), 'base64'))
219-
assert.deepEqual(Array.from(png.data.subarray(0, 4)), EXPLICIT_BACKGROUND)
242+
assert.deepEqual(await previewCornerPixel(OVERRIDE_TITLE), EXPLICIT_BACKGROUND)
220243
})
221244
})
25.3 KB
Loading

0 commit comments

Comments
 (0)