Files
ycode/scripts/verify-chat-header-integration.cjs
T

148 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Isolated production YCode App, actual preload/IPC; persisted history fixtures precede boot.
// Debugger transport is used for YCode screenshots/hover only. No WorkBuddy runtime access.
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/chat-header-integration-'))
const data = path.join(directory, 'data'), home = path.join(directory, 'home'), cwd = path.join(directory, 'workspace')
await Promise.all([fs.mkdir(data), fs.mkdir(home), fs.mkdir(cwd)])
const now = Date.now(), uuid = () => require('node:crypto').randomUUID()
const a = { id: uuid(), title: '标题编辑搜索验收 Alpha', cwd, createdAt: now, updatedAt: now + 1,
messages: Array.from({ length: 24 }, (_, index) => ({ id: uuid(), role: index % 2 ? 'assistant' : 'user', createdAt: now + index, status: 'done',
content: `${[1, 11, 23].includes(index) ? `命中段落${index}:中文定位 AlphaNeedle。\n\n` : ''}${Array.from({ length: 4 }, (_, line) => `历史段落 ${index}-${line},用于验证当前会话搜索滚动的位置。 Layout text keeps independent message boundaries.`).join('\n\n')}` })) }
const b = { id: uuid(), title: '另一个会话 Beta', cwd, createdAt: now, updatedAt: now,
messages: [{ id: uuid(), role: 'assistant', createdAt: now, status: 'done', content: '另一会话独立内容 Different conversation text.' }] }
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [a, b] }))
const env = { ...process.env, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_QA_DIRECTORY: directory,
YCODE_QA_HEADER_FIXTURES: JSON.stringify([a.id, b.id]), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '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('HEADER_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 90000)
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(-6000)); process.exitCode = code ?? 1
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
app.disableHardwareAcceleration()
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY, [a, b] = JSON.parse(process.env.YCODE_QA_HEADER_FIXTURES)
const relative = path.relative(path.resolve('.tmp'), directory)
assert.ok(relative.startsWith('chat-header-integration-') && !relative.includes('..') && !path.isAbsolute(relative))
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data')); app.setPath('home', path.join(directory, 'home'))
let main, phase = 'boot'; const checks = [], snapshots = {}, rendererErrors = []
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source) {
let timer
try { return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout')), 12000)
})]) } catch(error) { throw Error(error.message + ' — ' + source.slice(0, 220)) } finally { clearTimeout(timer) }
}
async function wait(source, label) { for (let i = 0; i < 250; i++) { const value = await read(source); if (value) return value; await pause(25) } 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 click(selector) { await read(`(()=>{const n=document.querySelector(${JSON.stringify(selector)});if(!n)throw Error('Missing selector '+${JSON.stringify(selector)});n.click()})()`); await pause(40) }
async function fill(selector, value) { await read(`(()=>{const n=document.querySelector(${JSON.stringify(selector)});if(!n)throw Error('Missing input');n.focus();Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(n,${JSON.stringify(value)});n.dispatchEvent(new Event('input',{bubbles:true}))})()`); await pause(80) }
async function key(key, selector, extra = {}) { await read(`(()=>{const n=${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'window'};if(!n)throw Error('Missing key target');n.dispatchEvent(new KeyboardEvent('keydown',{key:${JSON.stringify(key)},bubbles:true,cancelable:true,...${JSON.stringify(extra)}}))})()`); await pause(70) }
async function shot(name) { 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 snapshot(name) {
return snapshots[name] = await read(`(()=>{const all=CSS.highlights.get('yc-chat-search'),current=CSS.highlights.get('yc-chat-search-current'),ranges=current?[...current]:[],r=ranges[0],b=r?.getBoundingClientRect(),scroller=document.querySelector('.chat-scroll'),s=scroller?.getBoundingClientRect();return {title:document.querySelector('.workbuddy-topbar-title')?.textContent,searchOpen:!!document.querySelector('.cb-chat-search'),keyword:document.querySelector('.cb-chat-search__input')?.value,counter:document.querySelector('.cb-chat-search__counter')?.textContent,highlights:all?.size||0,current:current?.size||0,currentText:r?.toString(),currentConnected:r?.startContainer.isConnected,currentRect:b?{x:b.x,y:b.y,width:b.width,height:b.height}:null,scrollTop:scroller?.scrollTop,scrollHeight:scroller?.scrollHeight,viewport:s?{top:s.top,height:s.height,bottom:s.bottom}:null,baseColor:getComputedStyle(document.querySelector('.chat-thread'),'::highlight(yc-chat-search)').backgroundColor,currentColor:getComputedStyle(document.querySelector('.chat-thread'),'::highlight(yc-chat-search-current)').backgroundColor}})()`)
}
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady()
for (let i = 0; i < 200; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(25) }
assert.ok(main); await wait('!!window.ycode&&!!document.querySelector(".source-chat-header")', 'source header installed')
assert.equal(path.resolve((await read('window.ycode.app.info()')).configDir), path.join(directory, 'data'))
main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push(event.message) })
main.webContents.debugger.attach('1.3')
await main.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { width: 1336, height: 901, deviceScaleFactor: 1, mobile: false })
await pause(250)
await step('title-enter-persistence-and-escape')
await click('.workbuddy-topbar-edit-button')
await wait('!!document.querySelector(".workbuddy-topbar-title-input")', 'title editor')
await fill('.workbuddy-topbar-title-input', '已修改标题 中文 Latin')
await key('Enter', '.workbuddy-topbar-title-input')
await wait(`window.ycode.session.list().then(s=>s.find(x=>x.id===${JSON.stringify(a)})?.title==='已修改标题 中文 Latin')`, 'title native persistence')
check('title Enter updates real session registry', (await read('window.ycode.session.list()')).find(item => item.id === a).title, '已修改标题 中文 Latin')
check('title Enter updates visible header', await read('document.querySelector(".workbuddy-topbar-title-area .workbuddy-topbar-title").textContent.trim()'), '已修改标题 中文 Latin')
await click('.workbuddy-topbar-edit-button'); await fill('.workbuddy-topbar-title-input', 'Escape不应保存')
await key('Escape', '.workbuddy-topbar-title-input')
check('Escape leaves saved title unchanged', (await read('window.ycode.session.list()')).find(item => item.id === a).title, '已修改标题 中文 Latin')
check('Escape leaves visible title unchanged', await read('document.querySelector(".workbuddy-topbar-title-area .workbuddy-topbar-title").textContent.trim()'), '已修改标题 中文 Latin')
const persisted = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
check('title is physically persisted to isolated sessions.json', persisted.sessions.find(item => item.id === a).title, '已修改标题 中文 Latin')
await step('panel-toggle-visibility-and-hover')
check('closed right panel exposes source header toggle', await read(`!!document.querySelector('.source-chat-header [title="打开右侧栏"]')`), true)
await click('.source-chat-header [title="打开右侧栏"]'); await pause(350)
check('open right panel hides header toggle', await read(`!!document.querySelector('.source-chat-header [title="打开右侧栏"]')`), false)
check('header toggle opens actual workbench', await read('!!document.querySelector(".yc-workbench")'), true)
await click('.sidebar-next-collapse-toggle'); await pause(350)
check('closing panel restores header toggle', await read(`!!document.querySelector('.source-chat-header [title="打开右侧栏"]')`), true)
const hover = await read(`(()=>{const n=document.querySelector('.source-chat-header [title="对话内搜索"]'),b=n.getBoundingClientRect();return {x:b.x+b.width/2,y:b.y+b.height/2,before:getComputedStyle(n).backgroundColor}})()`)
await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hover.x, y: hover.y }); await pause(200)
check('real pointer hover reaches search control', await read(`document.querySelector('.source-chat-header [title="对话内搜索"]').matches(':hover')`), true)
await shot('header-search-hover')
await step('chinese-search-navigation-and-highlights')
await click('.source-chat-header [title="对话内搜索"]')
await fill('.cb-chat-search__input', '中文定位')
await wait('CSS.highlights.get("yc-chat-search")?.size===3', 'three Chinese matches')
let state = await snapshot('chinese-first')
check('Chinese search has3 actual native Highlight ranges', state.highlights, 3)
check('one active native Highlight', state.current, 1)
check('active Chinese hit is exact selected text', state.currentText, '中文定位')
check('source Chinese counter is1/3', state.counter?.replaceAll(' ', ''), '1/3')
check('base hit source color', state.baseColor, 'rgb(197, 238, 224)')
check('current hit source color', state.currentColor, 'rgb(78, 203, 160)')
await click('.cb-chat-search__nav[aria-label="下一个(Enter)"]'); await pause(100)
const next = await snapshot('chinese-next')
check('next navigates counter2/3', next.counter?.replaceAll(' ', ''), '2/3')
check('next hit scrolls conversation forward', next.scrollTop, value => value > state.scrollTop + 200)
check('next active hit is visibly centered in chat scroller', next, value => value.currentRect?.y >= value.viewport?.top && value.currentRect.y < value.viewport.bottom)
await click('.cb-chat-search__nav[aria-label="上一个(Shift+Enter)"]'); await pause(100)
const previous = await snapshot('chinese-prev')
check('prev returns counter1/3', previous.counter?.replaceAll(' ', ''), '1/3')
check('prev scrolls back toward first hit', previous.scrollTop, value => value < next.scrollTop - 200)
await key('Enter', '.cb-chat-search__input'); check('Enter navigates next match', (await snapshot('enter-next')).counter?.replaceAll(' ', ''), '2/3')
await key('Enter', '.cb-chat-search__input', { shiftKey: true }); check('ShiftEnter navigates previous match', (await snapshot('shift-enter-prev')).counter?.replaceAll(' ', ''), '1/3')
await shot('header-chinese-search')
await key('Escape', '.cb-chat-search__input')
check('Escape closes search UI and removes both CSS highlights', await read('[!!document.querySelector(".cb-chat-search"),CSS.highlights.has("yc-chat-search"),CSS.highlights.has("yc-chat-search-current")]'), [false, false, false])
await step('ctrl-f-latin-case-insensitive-and-session-cleanup')
await key('f', undefined, { ctrlKey: true }); await wait('!!document.querySelector(".cb-chat-search__input")', 'CtrlF opens current conversation search')
await fill('.cb-chat-search__input', 'alphaneedle')
await wait('CSS.highlights.get("yc-chat-search")?.size===3', 'three Latin case-insensitive matches')
state = await snapshot('latin-first')
check('Latin search case-insensitive exact content match', state.currentText, 'AlphaNeedle')
check('Latin CtrlF search counter1/3', state.counter?.replaceAll(' ', ''), '1/3')
await shot('header-latin-search')
await click(`[data-session-id="${b}"]`); await pause(200)
state = await snapshot('session-b-cleared')
check('switching session leaves no previous text hit ranges', [state.highlights, state.current], [0, 0])
check('switching session leaves no connected old active hit', state.currentConnected === true, false)
await click(`[data-session-id="${a}"]`); await pause(200)
if (await read('!!document.querySelector(".cb-chat-search")')) await key('Escape', '.cb-chat-search__input')
await key('f', undefined, { ctrlKey: true }); await fill('.cb-chat-search__input', '不存在的查询 no-match')
await pause(100); state = await snapshot('no-match')
check('no-match has zero ranges/current', [state.highlights, state.current], [0, 0])
check('no-match disables both navigation buttons', await read('[...document.querySelectorAll(".cb-chat-search__nav")].map(n=>n.disabled)'), [true, true])
await key('Escape', '.cb-chat-search__input'); await shot('header-final')
check('no renderer errors', rendererErrors, [])
const report = { result: checks.every(check => check.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated production YCode App/preload/IPC. Two persisted text history fixtures, no upstream calls or fake IPC. Native CSS Highlights queried on actual rendered messages; hover/screenshots use YCode-only debugger.', checks, snapshots, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('HEADER_INTEGRATION_' + report.result + ' ' + directory)
for (const item of checks.filter(check => !check.pass)) console.log('HEADER_FAILURE ' + JSON.stringify(item))
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); if (main?.webContents.debugger.isAttached()) await shot('failure').catch(() => undefined); app.exit(1)
})
}