// TODO 18: verify the REA-recovered WorkBuddy "Discover Apps" shell against a real isolated YCode renderer. // WorkBuddy source CSS: REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94 // (contexts-BkjwYSZT.css SHA-256 50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380). const fs = require('node:fs/promises') const path = require('node:path') const assert = require('node:assert/strict') if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/app-discover-')) const data = path.join(directory, 'data') const home = path.join(directory, 'home') const workspace = path.join(directory, 'workspace') await Promise.all([fs.mkdir(data), fs.mkdir(home), fs.mkdir(workspace)]) const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' } delete env.ELECTRON_RUN_AS_NODE delete env.ELECTRON_RENDERER_URL const child = require('node:child_process').spawn( require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] } ) let output = '' child.stdout.on('data', bytes => { output += bytes if (bytes.toString().includes('APP_DISCOVER_')) process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes if (bytes.toString().includes('APP_DISCOVER_')) process.stderr.write(bytes) }) const timer = setTimeout(() => child.kill(), 180000) child.once('exit', async code => { clearTimeout(timer) await fs.writeFile(path.join(directory, 'process.log'), output) console.log(JSON.stringify({ directory, exitCode: code })) if (code) console.error(output.slice(-8000)) process.exitCode = code ? 1 : 0 }) })().catch(error => { console.error(error) process.exitCode = 1 }) } else { const { app, BrowserWindow } = require('electron') const { pathToFileURL } = require('node:url') const directory = process.env.YCODE_QA_DIRECTORY assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('app-discover-')) app.disableHardwareAcceleration() app.setPath('userData', path.join(directory, 'data')) app.setPath('sessionData', path.join(directory, 'data')) app.setPath('home', path.join(directory, 'home')) let main let phase = 'boot' const checks = [] const snapshots = {} const rendererErrors = [] const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) async function read(source, win = main) { let timer try { return await Promise.race([ win.webContents.executeJavaScript(source), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000) }) ]) } catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) } } async function wait(source, label, win = main) { for (let index = 0; index < 300; index++) { const value = await read(source, win) if (value) return value await pause(30) } throw Error('Timeout: ' + label) } async function step(label) { phase = label await fs.writeFile(path.join(directory, 'phase.txt'), label) } function check(label, actual, expected) { const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) } async function shot(name) { if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3') const image = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(image.data, 'base64')) } async function mouseMove(selector) { const point = await read(`(()=>{ const node=document.querySelector(${JSON.stringify(selector)}); if(!node)return null; const box=node.getBoundingClientRect(); return [box.x+box.width/2,box.y+box.height/2] })()`) assert.ok(point, 'mouse target: ' + selector) if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3') await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point[0], y: point[1] }) await pause(80) } async function mouseClick(selector) { const point = await read(`(()=>{ const node=document.querySelector(${JSON.stringify(selector)}); if(!node)return null; const box=node.getBoundingClientRect(); return [box.x+box.width/2,box.y+box.height/2] })()`) assert.ok(point, 'mouse target: ' + selector) if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3') await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: point[0], y: point[1], button: 'left', clickCount: 1 }) await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point[0], y: point[1], button: 'left', clickCount: 1 }) await pause(100) } const openPanel = async () => { await read(`(()=>{ const trigger=document.querySelector('.industry-template-switcher__trigger'); if(!trigger)return false; trigger.click(); return true })()`) await wait(`!!document.querySelector('.app-discover-popover')`, 'discover panel open') await pause(100) } const fillSearch = async value => { await read(`(()=>{ const input=document.querySelector('.app-discover-popover__search-input'); if(!input)return false; const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(input,${JSON.stringify(value)}); input.dispatchEvent(new Event('input',{bubbles:true})); return true })()`) await pause(100) } const chooseApp = async tab => { await read(`(()=>{ const item=document.querySelector('.industry-template-switcher__group-items [data-template-id=${JSON.stringify(tab)}]'); if(!item)return false; item.click(); return true })()`) await wait(`(()=>{ const page=document.querySelector('.yc-capability-center'); return page&&page.getAttribute('data-capability-tab')===${JSON.stringify(tab)} })()`, 'capability page ' + tab) await pause(80) } const layoutSource = `(()=>{ const trigger=document.querySelector('.industry-template-switcher__trigger'); const pop=document.querySelector('.app-discover-popover'); const panel=pop?.querySelector('.industry-template-switcher__panel'); const searchHeader=pop?.querySelector('.industry-template-switcher__search-header'); const search=pop?.querySelector('.industry-template-switcher__search'); const list=pop?.querySelector('.industry-template-switcher__list'); if(!trigger||!pop||!panel||!searchHeader||!search||!list)return null; const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]}; const style=node=>getComputedStyle(node); const font=node=>{const s=style(node);return s.fontSize+'/'+s.fontWeight+'/'+s.lineHeight}; const items=[...pop.querySelectorAll('.industry-template-switcher__group-items > .industry-template-switcher__item')]; const headers=[...pop.querySelectorAll('.industry-template-switcher__group-title')]; return { theme:document.documentElement.dataset.theme??'light', trigger:box(trigger), triggerStyle:{padding:style(trigger).padding,height:style(trigger).height,radius:style(trigger).borderRadius, background:style(trigger).backgroundColor,borderColor:style(trigger).borderTopColor,color:style(trigger).color, font:font(trigger),gap:style(trigger).gap}, popover:{box:box(pop),position:style(pop).position,width:style(pop).width,maxHeight:style(pop).maxHeight, padding:style(pop).padding,radius:style(pop).borderRadius,background:style(pop).backgroundColor, borderWidth:style(pop).borderTopWidth,borderColor:style(pop).borderTopColor,display:style(pop).display, overflow:style(pop).overflow,shadow:style(pop).boxShadow}, panel:box(panel), searchHeader:{box:box(searchHeader),paddingBottom:style(searchHeader).paddingBottom,marginBottom:style(searchHeader).marginBottom}, search:{box:box(search),display:style(search).display,padding:style(search).padding,borderColor:style(search).borderTopColor}, list:{box:box(list),gap:style(list).gap,overflowY:style(list).overflowY}, headers:headers.map(node=>({text:node.textContent,font:font(node),color:style(node).color})), items:items.map(item=>({ id:item.getAttribute('data-template-id'),box:box(item),padding:style(item).padding,gap:style(item).gap, radius:style(item).borderRadius,minHeight:style(item).minHeight, title:item.querySelector('.industry-template-switcher__item-title')?.textContent, titleFont:font(item.querySelector('.industry-template-switcher__item-title')), desc:item.querySelector('.industry-template-switcher__item-desc')?.textContent, descFont:font(item.querySelector('.industry-template-switcher__item-desc')), icon:box(item.querySelector('.industry-template-switcher__template-icon')), selected:item.getAttribute('aria-selected') })), text:pop.textContent.replace(/\\s+/g,' ').trim(), recentGrid:!!pop.querySelector('.industry-template-switcher__recent-grid'), empty:pop.querySelector('.industry-template-switcher__empty')?.textContent??null } })()` ;(async () => { await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) await app.whenReady() main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') ?? BrowserWindow.getAllWindows()[0] assert.ok(main) main.setSize(1336, 902) main.showInactive() main.webContents.debugger.attach('1.3') main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push({ windowId: main.id, message }) }) await wait(`!!window.ycode && !!document.querySelector('.conversation-list')`, 'main renderer') await wait(`!!document.querySelector('.industry-template-switcher__trigger')`, 'discover trigger') await pause(500) await step('trigger-semantics') check('the trigger is a collapsed dialog button', await read(`(()=>{ const node=document.querySelector('.industry-template-switcher__trigger'); return [node.getAttribute('aria-haspopup'),node.getAttribute('aria-expanded'),node.getAttribute('aria-label')] })()`), ['dialog', 'false', '发现应用']) await step('closed-state-light') snapshots.closedLight = await read(`(()=>{ const node=document.querySelector('.industry-template-switcher__trigger'),s=getComputedStyle(node),r=node.getBoundingClientRect(); return {box:[r.x,r.y,r.width,r.height],background:s.backgroundColor,border:s.borderTopColor,color:s.color, padding:s.padding,radius:s.borderRadius,font:s.fontSize+'/'+s.fontWeight+'/'+s.lineHeight} })()`) check('the trigger keeps the recovered 98x28 pill geometry', snapshots.closedLight.box.slice(2).map(Math.round), [98, 28]) check('the trigger keeps the recovered light/secondary paint', [snapshots.closedLight.background, snapshots.closedLight.border, snapshots.closedLight.radius], ['rgb(242, 242, 242)', 'rgb(235, 235, 235)', '100px']) check('the trigger keeps the recovered 12/500/20 typography', snapshots.closedLight.font, '12px/500/20px') await step('open-light') await openPanel() const light = await read(layoutSource) snapshots.light = light check('the popover is anchored to the trigger right edge', Math.abs(light.popover.box[0] + 240 - (light.trigger[0] + light.trigger[2])) <= 1, true) check('the popover sits 6px below the trigger', Math.round(light.popover.box[1] - (light.trigger[1] + light.trigger[3])), 6) check('the popover keeps 240px max width and 420px max height', [light.popover.width, light.popover.maxHeight], ['240px', '420px']) check('the popover keeps 8px padding and 16px radius', [light.popover.padding, light.popover.radius], ['8px', '16px']) check('the light popover uses the recovered white surface and hairline', [light.popover.background, light.popover.borderWidth, light.popover.borderColor], ['rgb(255, 255, 255)', '1px', 'rgb(222, 222, 222)']) check('the popover is a fixed layered surface', [light.popover.position, light.popover.display], ['fixed', 'flex']) check('the search header and 28px search field match the source geometry', [Math.round(light.searchHeader.box[2]), Math.round(light.searchHeader.box[3]), Math.round(light.search.box[2]), Math.round(light.search.box[3])], [222, 33, 222, 28]) check('the search header keeps the recovered divider spacing', [light.searchHeader.paddingBottom, light.searchHeader.marginBottom], ['4px', '4px']) check('the list has the recovered 4px gap and transparent scroll surface', [light.list.gap, light.list.overflowY], ['4px', 'auto']) check('the recommended group contains exactly three local capability entries', light.items.map(item => item.id), ['experts', 'skills', 'connectors']) check('each app entry keeps the 218x48 / 8px / 12px geometry', light.items.map(item => [Math.round(item.box[2]), Math.round(item.box[3]), item.padding, item.gap]), [[218, 48, '8px', '12px'], [218, 48, '8px', '12px'], [218, 48, '8px', '12px']]) check('each app entry keeps an 8px radius and 24px icon', light.items.map(item => [item.radius, Math.round(item.icon[2]), Math.round(item.icon[3])]), [['8px', 24, 24], ['8px', 24, 24], ['8px', 24, 24]]) check('entry titles and descriptions keep 13/16 and 11/14 typography', light.items.map(item => [item.titleFont, item.descFont]), [['13px/400/16px', '11px/400/14px'], ['13px/400/16px', '11px/400/14px'], ['13px/400/16px', '11px/400/14px']]) check('group headings keep the recovered 11/14 tertiary text', light.headers.map(item => [item.text, item.font]), [['推荐', '11px/400/14px']]) check('the trigger is marked expanded while the panel is open', await read(`document.querySelector('.industry-template-switcher__trigger').getAttribute('aria-expanded')`), 'true') check('the trigger chevron rotates open', await read(`getComputedStyle(document.querySelector('.industry-template-switcher__chevron')).transform`), value => typeof value === 'string' && value !== 'none') await step('hover-enter') await mouseMove('.industry-template-switcher__group-items [data-template-id="skills"]') check('an unselected entry reveals the 24px 进入 action on hover', await read(`(()=>{ const item=document.querySelector('[data-template-id="skills"]'); const action=item.querySelector('.industry-template-switcher__item-action'); const button=item.querySelector('.industry-template-switcher__item-action-btn'); const s=getComputedStyle(action),b=getComputedStyle(button); return [s.display,b.height,button.textContent] })()`), ['flex', '24px', '进入']) await shot('app-discover-light') await step('search-match') await fillSearch('技能') const searched = await read(layoutSource) snapshots.search = searched check('search narrows the list to the matching local app', searched.items.map(item => item.id), ['skills']) check('search highlights the matching glyphs', await read(`document.querySelector('.industry-template-switcher__match')?.textContent`), '技能') check('search hides the recent/recommended grouping', searched.headers, []) await fillSearch('不存在') check('search shows the recovered empty state', await read(`document.querySelector('.industry-template-switcher__empty')?.textContent`), '未找到匹配的应用') await step('keyboard-navigation') await fillSearch('中心') await read(`document.querySelector('.app-discover-popover__search-input').dispatchEvent( new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}) )`) await pause(80) check('ArrowDown moves focus from search to the first result', await read(`document.activeElement?.getAttribute('data-template-id')`), 'experts') await read(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}))`) await pause(50) check('subsequent ArrowDown moves through results in visual order', await read(`document.activeElement?.getAttribute('data-template-id')`), 'skills') await step('navigate-experts') await fillSearch('') await chooseApp('experts') check('clicking an app opens the real expert capability page', await read(`document.querySelector('.yc-capability-center')?.getAttribute('data-capability-tab')`), 'experts') check('the local app id is persisted as the most recent', await read(`JSON.parse(localStorage.getItem('ycode.app-discover.recent.v1')||'[]')`), ['experts']) check('the popover closes after navigation', await read(`!document.querySelector('.app-discover-popover')`), true) await step('active-app-exit') await openPanel() const activeApp = await read(`(()=>{ const item=document.querySelector('.industry-template-switcher__group-items [data-template-id="experts"]'); return {tag:item?.tagName,selected:item?.getAttribute('aria-selected'), action:item?.querySelector('.industry-template-switcher__item-action-btn')?.textContent} })()`) check('the active app uses the source non-button option with check state', [activeApp.tag, activeApp.selected, activeApp.action], ['DIV', 'true', '退出']) await read(`document.querySelector('[data-template-id="experts"] .industry-template-switcher__item-action-btn').click()`) await wait(`!document.querySelector('.yc-capability-center') && !document.querySelector('.app-discover-popover')`, 'exit active app to chat') check('退出 returns from the active capability page to chat', await read(`!document.querySelector('.yc-capability-center')`), true) await step('navigate-skills') await openPanel() await fillSearch('技能') await chooseApp('skills') check('clicking the skill app opens the real skills page', await read(`document.querySelector('.yc-capability-center')?.getAttribute('data-capability-tab')`), 'skills') check('recent app order puts the latest choice first', await read(`JSON.parse(localStorage.getItem('ycode.app-discover.recent.v1')||'[]')`), ['skills', 'experts']) await step('navigate-connectors') await openPanel() await fillSearch('连接器') await chooseApp('connectors') check('clicking the connector app opens the real connectors page', await read(`document.querySelector('.yc-capability-center')?.getAttribute('data-capability-tab')`), 'connectors') check('recent app history is de-duplicated and ordered', await read(`JSON.parse(localStorage.getItem('ycode.app-discover.recent.v1')||'[]')`), ['connectors', 'skills', 'experts']) await step('recent-after-reload') main.webContents.reload() await wait(`!!document.querySelector('.industry-template-switcher__trigger')`, 'renderer after reload') await pause(600) await openPanel() const recent = await read(`(()=>{ const pop=document.querySelector('.app-discover-popover'); return { grid:[...pop.querySelectorAll('.industry-template-switcher__recent-grid [data-template-id]')].map(node=>node.getAttribute('data-template-id')), labels:[...pop.querySelectorAll('.industry-template-switcher__recent-grid .industry-template-switcher__grid-label')].map(node=>node.textContent), gridDisplay:getComputedStyle(pop.querySelector('.industry-template-switcher__recent-grid')).display, columns:getComputedStyle(pop.querySelector('.industry-template-switcher__recent-grid')).gridTemplateColumns } })()`) snapshots.recent = recent check('recent apps survive a renderer reload in recency order', recent.grid, ['connectors', 'skills', 'experts']) check('recent apps use a three-column grid and the local labels', [recent.gridDisplay, recent.columns.trim().split(/\s+/).length, recent.labels], ['grid', 3, ['连接器中心', '技能中心', '专家中心']]) await step('escape-close') await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`) await wait(`!document.querySelector('.app-discover-popover')`, 'Escape closes discover panel') check('Escape closes the panel and restores trigger focus', await read(`document.activeElement===document.querySelector('.industry-template-switcher__trigger')`), true) await step('outside-close') await openPanel() await mouseClick('.conversation-list-brand-line') await wait(`!document.querySelector('.app-discover-popover')`, 'outside click closes discover panel') check('outside pointer input closes the panel', await read(`!document.querySelector('.app-discover-popover')`), true) await step('dark-theme') await read(`(()=>{ localStorage.setItem('ycode:theme','dark'); window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:'dark'})); return true })()`) await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme applied') await openPanel() snapshots.dark = await read(`(()=>{ const trigger=document.querySelector('.industry-template-switcher__trigger'); const pop=document.querySelector('.app-discover-popover'); const item=pop.querySelector('.industry-template-switcher__template-icon'); return { theme:document.documentElement.dataset.theme, triggerBackground:getComputedStyle(trigger).backgroundColor, popoverBackground:getComputedStyle(pop).backgroundColor, popoverBorder:getComputedStyle(pop).borderTopColor, itemIconBackground:getComputedStyle(item).backgroundColor, textColor:getComputedStyle(pop.querySelector('.industry-template-switcher__item-title')).color } })()`) check('dark theme uses the recovered WorkBuddy trigger/popover paint', [snapshots.dark.triggerBackground, snapshots.dark.popoverBackground, snapshots.dark.popoverBorder], ['rgb(46, 46, 46)', 'rgb(36, 36, 36)', 'rgb(61, 61, 61)']) check('dark theme keeps the recovered primary text and icon surface', [snapshots.dark.textColor, snapshots.dark.itemIconBackground], ['rgb(255, 255, 255)', 'rgb(41, 41, 41)']) await shot('app-discover-dark') await step('restore-light') await read(`(()=>{ localStorage.setItem('ycode:theme','light'); window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:'light'})); return true })()`) await wait(`document.documentElement.dataset.theme==='light'`, 'light theme restored') check('discover panel follows the shared light theme back', await read(`getComputedStyle(document.querySelector('.app-discover-popover')).backgroundColor`), 'rgb(255, 255, 255)') check('no renderer error logs', rendererErrors, []) const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated YCode App/preload/renderer at 1336x902; TODO 18 Discover Apps trigger, popover geometry, search/empty/keyboard/outside/Escape behavior, recent persistence, three local capability routes, light/dark paint.', sourceEvidence: [ 'ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94', 'ev_7eb66ea8d5949eb7d872f1a93a97894a811b3beb7a02eabd81a500409f1045e4', 'ev_bd22e46c95d3887daba16dbe319389ad74e10afea8f71af9a7639e8bce95ebb4', 'ev_d3527391f609e4a8cb4d194a24ee17dcbbdfc0a2d58db240ef3686119694d85f', 'contexts-BkjwYSZT.css SHA-256 50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380' ], snapshots, checks, rendererErrors } await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) console.log('APP_DISCOVER_' + report.result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) { console.log('APP_DISCOVER_FAILURE ' + JSON.stringify(failure)) } app.exit(report.result === 'PASS' ? 0 : 1) })().catch(async error => { await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) console.error(error) app.exit(1) }) }