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