Files
ycode/scripts/verify-app-discover.cjs

490 lines
25 KiB
JavaScript

// 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_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)
})
}