11import assert from 'node:assert/strict'
2- import { mkdirSync , rmSync , writeFileSync } from 'node:fs'
3- import { dirname , join } from 'node:path'
42import { $ , $$ , 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'
74import { resetUserData , seedEmptyProject } from './helpers/seed-config.ts'
8- import { waitForAgentIdle , waitForPromptReady } from './helpers.ts'
5+ import { waitForPromptReady } from './helpers.ts'
96import { setComposerValue } from './helpers/composer.ts'
10- import { saveThreePaneScreenshot } from './helpers/screenshot.ts'
7+ import { saveElementScreenshot } from './helpers/screenshot.ts'
118
129const 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
3614const 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+
7869async 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+
104142describe ( '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} )
0 commit comments