chore: establish YCode replication baseline and TODO

This commit is contained in:
xiaohan.yuan committed 2026-10-17 02:43:49 +08:00
commit 6dc59f89ba
1118 files changed
+196002

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
// 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)
})
}