/** Own YCode only. Native menu inspection; no sends, mode changes or MCP connections. */ import fs from 'node:fs/promises' import path from 'node:path' import assert from 'node:assert/strict' const directory = await fs.mkdtemp(path.resolve('.tmp/live-add-menu-')) const targets = await (await fetch('http://127.0.0.1:9237/json/list')).json() assert.ok(!targets.some(item => item.type === 'page' && item.url.includes('window=settings')), 'Settings edit in progress') const target = targets.find(item => item.type === 'page' && /\/ycode\/out\/renderer\//i.test(item.url) && !item.url.includes('window=settings')) assert.ok(target, 'Only own YCode renderer may be inspected') const socket = new WebSocket(target.webSocketDebuggerUrl), pending = new Map(), exceptions = [] let nextId = 0 await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject }) socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text); const request = pending.get(message.id); if (!request) return; pending.delete(message.id); clearTimeout(request.timer); message.error ? request.reject(Error(message.error.message)) : request.resolve(message.result) } function send(method, params = {}) { return new Promise((resolve, reject) => { const id = ++nextId, timer = setTimeout(() => { pending.delete(id); reject(Error('Own YCode request timeout: ' + method)) }, 15000); pending.set(id, { resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params })) }) } async function evaluate(expression) { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (result.exceptionDetails) throw Error('Own YCode evaluation failed'); return result.result.value } const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) async function wait(expression) { for (let count = 0; count < 120; count++) { const result = await evaluate(expression); if (result) return result; await pause(50) }; throw Error('Own menu did not reach expected state: ' + expression) } async function point(selector) { return evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Control absent');const b=e.getBoundingClientRect();if(!b.width||!b.height)throw Error('Control hidden');return{x:b.x+b.width/2,y:b.y+b.height/2}})()`) } async function click(selector) { const at = await point(selector); await send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at }); await send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at, button: 'left', buttons: 1, clickCount: 1 }); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at, button: 'left', buttons: 0, clickCount: 1 }); await pause(200) } async function hover(id) { await send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...await point('.wb-composer__add-button') }); await pause(20); await send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...await point(`.cr-add-menu__popover [data-add-menu-id="${id}"]`) }); await pause(240) } async function screenshot(name) { await evaluate('document.fonts.ready.then(()=>true)'); await pause(180); const result = await send('Page.captureScreenshot', { format: 'png' }); await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(result.data, 'base64')) } const checks = [] function check(label, actual, expected = true) { assert.deepEqual(actual, expected, label); checks.push({ label, pass: true }) } try { await send('Runtime.enable') const before = await evaluate(`(()=>{if(document.querySelector('.cr-send-button--stop,.wb-composer__attachments,.wb-composer__resources,.composer__edit-banner')||document.querySelector('.wb-composer__input')?.value.trim())throw Error('User has active work');return{sessionId:document.querySelector('[data-session-id][data-selected=true]')?.dataset.sessionId??null,configDir:window.ycode.app.info().then(info=>info.configDir)}})()`) assert.ok(before.sessionId, 'Preserve an existing conversation; do not replace an unsent task') const history = await evaluate(`window.ycode.session.list().then(items=>({count:items.length,session:items.find(item=>item.id===${JSON.stringify(before.sessionId)})}))`) await click(`[data-session-id="${before.sessionId}"]`) await wait('!!document.querySelector(".chat-pane:not([style*=none]) .wb-composer__add-button")') if (await evaluate('document.querySelector(".wb-composer__add-button")?.getAttribute("aria-expanded")==="true"')) await click('.wb-composer__add-button') await click('.wb-composer__add-button') await wait('!!document.querySelector(".cr-add-menu__popover")') check('source plus expands parent', await evaluate('document.querySelector(".wb-composer__add-button").getAttribute("aria-expanded")'), 'true') const measurements = await evaluate(`(()=>{const parent=document.querySelector('.cr-add-menu__popover'),row=parent.querySelector('[data-add-menu-id=experts]'),button=document.querySelector('.wb-composer__add-button'),rect=e=>{const b=e.getBoundingClientRect();return[b.x,b.y,b.width,b.height]},style=getComputedStyle(parent.querySelector('.cr-menu')),p=['font-size','line-height','border-radius','padding-top','padding-right','padding-bottom','padding-left','background-color','box-shadow'];return{parent:rect(parent),row:rect(row),button:rect(button),styles:Object.fromEntries(p.map(key=>[key,style.getPropertyValue(key)])),entries:[...parent.querySelectorAll('[data-add-menu-id]')].map(e=>e.dataset.addMenuId)}})()`) check('live parent width matches original', measurements.parent[2], 200) check('live row height matches original', measurements.row[3], 32) check('live trigger size matches original', measurements.button.slice(2), [32, 32]) check('live parent radius matches original', measurements.styles['border-radius'], '16px') check('live entries contain source actions', ['add-files', 'mode', 'experts', 'skills', 'connectors'].every(id => measurements.entries.includes(id))) await screenshot('parent') for (const [id, selector] of [['add-files', '.cr-files-submenu'], ['mode', '.cr-mode-submenu'], ['experts', '.yc-expert-picker[data-embedded]'], ['skills', '.yc-skill-picker[data-embedded]'], ['connectors', '.cr-connectors-submenu']]) { await hover(id); await wait(`!!document.querySelector(${JSON.stringify(selector)})`) check(id + ' hover keeps parent expanded', await evaluate('document.querySelector(".wb-composer__add-button").getAttribute("aria-expanded")'), 'true') if (id === 'experts' || id === 'skills') { const panel = await evaluate(`(()=>{const panel=document.querySelector(${JSON.stringify(selector)}).querySelector('[role=menu]'),s=getComputedStyle(panel),b=panel.getBoundingClientRect();return{background:s.backgroundColor,radius:s.borderRadius,width:b.width,inside:b.y>=0&&b.bottom<=innerHeight}})()`) check(id + ' panel inherits visible light background in theme portal', panel.background, 'rgb(255, 255, 255)') check(id + ' panel retains source rounded border', panel.radius, '16px') check(id + ' panel fits actual viewport', panel.inside) check(id + ' panel width matches source view', panel.width, id === 'experts' ? 240 : 320) } await screenshot(id) } await click(`[data-session-id="${before.sessionId}"]`) await wait('document.querySelector(".wb-composer__add-button").getAttribute("aria-expanded")==="false"') check('outside click dismisses whole menu', true) const after = await evaluate(`window.ycode.session.list().then(items=>({count:items.length,session:items.find(item=>item.id===${JSON.stringify(before.sessionId)})}))`) check('native menu inspection creates no session', after.count, history.count) check('native menu inspection changes no context', after.session, history.session) check('native menu inspection preserves draft', await evaluate('document.querySelector(".wb-composer__input").value'), '') check('native menu inspection preserves owner', await evaluate('document.querySelector("[data-session-id][data-selected=true]")?.dataset.sessionId'), before.sessionId) check('renderer raises no exception', exceptions, []) await click('.wb-composer__add-button'); await screenshot('ready-for-review') await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, measurements, messagesSent: 0, modeChanges: 0, connectorChanges: 0 }, null, 2)) console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory, messagesSent: 0, modeChanges: 0, connectorChanges: 0 })) } finally { socket.close() }