// Real isolated production Electron App + real preload/IPC. CDP below controls YCode only. // WorkBuddy expectations come from fixed REA source evidence, never this browser transport. 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/panel-integration-')) await fs.mkdir(path.join(directory, 'data')); await fs.mkdir(path.join(directory, 'home')) const cwd = path.join(directory, 'fixture-workspace'); await fs.mkdir(cwd) const now = Date.now(), fixtureId = () => require('node:crypto').randomUUID() const fixtures = ['Panel A', 'Panel B'].map((title, index) => ({ id: fixtureId(), title, cwd, createdAt: now + index, updatedAt: now + index, messages: [{ id: fixtureId(), role: 'user', content: 'Persisted panel-layout test fixture.', createdAt: now, status: 'done' }, ...(index === 0 ? [{ id: fixtureId(), role: 'assistant', content: 'A real resource is available.', createdAt: now + 1, status: 'done', parts: [{ type: 'text', text: 'A real resource is available.' }, ...[1, 2].map(number => ({ type: 'artifact', artifact: { id: `fixture-artifact-${number}`, name: 'panel-sentinel.txt', path: 'panel-sentinel.txt' } }))] }] : [])] })) await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions: fixtures })) await fs.writeFile(path.join(cwd, 'panel-sentinel.txt'), 'PANEL_NATIVE_RESOURCE_SENTINEL') const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1', YCODE_QA_PANEL_FIXTURES: JSON.stringify(fixtures) } 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('PANEL_')) process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes }) const timer = setTimeout(() => child.kill(), 150000) 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 assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('panel-integration-')) 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: ' + source.slice(0, 100))), 15000) })]) } catch(error) { throw Error(error.message + ' — ' + source.slice(0, 240)) } 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(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 }) } const rectSource = `selector=>{const n=document.querySelector(selector);if(!n)return null;const b=n.getBoundingClientRect(),s=getComputedStyle(n);return {x:b.x,y:b.y,width:b.width,height:b.height,transform:s.transform,opacity:s.opacity,transition:s.transition,transitionDuration:s.transitionDuration,transitionTimingFunction:s.transitionTimingFunction,position:s.position,z:s.zIndex,classes:n.className}}` async function snapshot(label) { const result = await read(`(()=>{const rect=${rectSource};return {viewport:[innerWidth,innerHeight],shell:rect('.app-shell'),sidebar:rect('.app-shell>.sidebar'),main:rect('[data-view-id="main-content"]'),detail:rect('[data-view-id="detail-panel"]'),workbench:rect('.yc-workbench'),leftDrawer:rect('.app-shell__left-drawer'),backdrop:rect('.app-shell__drawer-backdrop'),motion:document.querySelector('.app-shell').className,owner:document.querySelector('.yc-workbench')?.dataset.owner,selected:document.querySelector('[data-session-id][data-selected="true"]')?.dataset.sessionId,layouts:JSON.parse(localStorage.getItem('ycode.panel-layouts.v1')||'{}')}})()`) snapshots[label] = result; return result } async function click(selector) { await read(`(()=>{const n=document.querySelector(${JSON.stringify(selector)});if(!n)throw Error('Missing clickable '+${JSON.stringify(selector)});n.click()})()`) await pause(35) } async function resize(width) { // Renderer emulation avoids native getContentSize hangs on this Windows test host. await main.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { width, height: 901, deviceScaleFactor: 1, mobile: false }) await wait(`innerWidth===${width}`, 'viewport ' + width); await pause(400) } async function shot(name) { const shot = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(shot.data, 'base64')) } async function openPanel() { if (!(await read('!!document.querySelector(".yc-workbench")'))) await click('.source-chat-header button[title="打开右侧栏"]') await pause(400) } async function closePanel() { if (await read('!!document.querySelector(".sidebar-next-collapse-toggle")')) await click('.sidebar-next-collapse-toggle') await pause(400) } async function drag(selector, delta) { const point = await read(`(()=>{const n=document.querySelector(${JSON.stringify(selector)});if(!n)throw Error('Missing separator');const b=n.getBoundingClientRect(),x=b.x+b.width/2,y=b.y+100;n.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,button:0,clientX:x,clientY:y}));return {x,y}})()`) // Match real discrete input turns so React installs its drag effect before mousemove/up. await pause(50) await read(`window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,buttons:1,clientX:${point.x + delta},clientY:${point.y}}))`) await pause(40) await read(`window.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,button:0,clientX:${point.x + delta},clientY:${point.y}}))`) await pause(250) } async function select(id) { await click(`[data-session-id="${id}"]`); await pause(400) } async function createHost(kind) { await openPanel() if (!(await read(`!!document.querySelector('[data-entry-key="${kind}"]')`))) await click('[aria-label="新标签页"]') await click(`[data-entry-key="${kind}"]`) const selector = kind === 'browser' ? '[data-browser-host-id]' : '[data-terminal-id]' const attribute = kind === 'browser' ? 'data-browser-host-id' : 'data-terminal-id' return wait(`document.querySelector(${JSON.stringify(selector)})?.getAttribute(${JSON.stringify(attribute)})`, 'real App ' + kind + ' host mounted') } async function hostExists(kind, id, sessionId) { return read(`window.ycode.${kind}.get(${JSON.stringify(id)},${JSON.stringify(sessionId)}).then(()=>true,()=>false)`) } ;(async () => { // Persisted conversation fixtures avoid starting an upstream turn merely to expose chat controls. // Production session:list/messages IPC loads these unchanged; no renderer-store or IPC substitutes. const [a, b] = JSON.parse(process.env.YCODE_QA_PANEL_FIXTURES) const sentinel = 'PANEL_NATIVE_RESOURCE_SENTINEL' 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(30) } assert.ok(main); await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'real App') 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.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }) assert.equal(path.resolve((await read('window.ycode.app.info()')).configDir), path.join(directory, 'data')) await step('native-session-and-resource-setup') check('native session registry loads persisted conversations', (await read('window.ycode.session.list()')).map(item => item.id).sort(), [a.id, b.id].sort()) check('native resource reads from session workspace', await read(`window.ycode.resources.readText('panel-sentinel.txt',${JSON.stringify(a.id)})`), sentinel) await wait('!!document.querySelector("[data-session-id]")', 'native sessions loaded') await resize(1336); await select(a.id) await step('conversation-artifact-link') check('artifact link uses deduplicated real resource count', await read('document.querySelector(".artifact-slot-panel__action-btn")?.textContent'), '查看所有产物 (1)') await click('.artifact-slot-panel__action-btn'); await pause(250) check('artifact link opens real overview', await read('!!document.querySelector(".detail-new-tab-artifacts")'), true) check('source expanded chevron rotates right arrow down', await read('getComputedStyle(document.querySelector(".detail-new-tab-artifacts__chevron")).transform'), 'matrix(0, 1, -1, 0, 0, 0)') await click('.detail-new-tab-artifacts__title') check('artifact group collapses', await read('!!document.querySelector(".detail-new-tab-artifacts__body")'), false) await click('.detail-new-tab-artifacts__title') await closePanel() await step('default-width-drag-doubleclick') await openPanel() let state = await snapshot('default-open') check('ordinary detail defaults 360', state.detail?.width, 360) check('sidebar defaults 280', state.sidebar?.width, 280) check('main + sidebar + detail fills viewport', Math.round(state.sidebar.width + state.main.width + state.detail.width), 1336) check('source split motion returns idle', state.motion.includes('detail-panel-motion-idle'), true) await drag('.app-shell__resizer', -100) state = await snapshot('dragged-right'); check('right separator drag grows by100', state.detail?.width, 460) await read('document.querySelector(".app-shell__resizer").dispatchEvent(new MouseEvent("dblclick",{bubbles:true}))'); await pause(250) state = await snapshot('doubleclick-right'); check('right doubleclick restores available half', state.detail?.width, 528) await drag('.app-shell__sidebar-resizer', 40) state = await snapshot('dragged-sidebar'); check('sidebar separator drag width320', state.sidebar?.width, 320) await read('document.querySelector(".app-shell__sidebar-resizer").dispatchEvent(new MouseEvent("dblclick",{bubbles:true}))'); await pause(250) check('sidebar doubleclick restores280', (await snapshot('doubleclick-sidebar')).sidebar?.width, 280) await select(b.id); await closePanel() await step('resource-preview-and-session-layout') await read('window.dispatchEvent(new CustomEvent("ycode:preview-resource",{detail:{path:"panel-sentinel.txt"}}))') await wait(`document.querySelector('.yc-workbench')?.textContent.includes(${JSON.stringify(sentinel)})`, 'real native preview loaded') await pause(400); state = await snapshot('native-preview') check('first resource preview uses available half', state.detail?.width, 528) await drag('.app-shell__resizer', -72); check('session B custom width600', (await snapshot('session-b-width')).detail?.width, 600) check('empty-artifact session has no footer link', await read('!!document.querySelector(".artifact-slot-panel__action-btn")'), false) await select(a.id); check('session A width retained528', (await snapshot('session-a-retained')).detail?.width, 528) await read('window.dispatchEvent(new CustomEvent("ycode:preview-resource",{detail:{path:"panel-sentinel.txt"}}))') await wait('!!document.querySelector(".source-code-preview[data-source-code-state=ready]")', 'real Monaco preview in session A') const tabCount = await read('document.querySelectorAll(".artifact-tab").length') await click('.artifact-slot-panel__action-btn'); await pause(250) check('artifact link preserves existing resource tabs', await read('document.querySelectorAll(".artifact-tab").length'), tabCount + 1) await click('.artifact-slot-panel__action-btn') check('repeated artifact link reuses overview tab', await read('document.querySelectorAll(".artifact-tab").length'), tabCount + 1) await select(b.id); check('session B width retained600', (await snapshot('session-b-retained')).detail?.width, 600) await shot('wide-native-preview') await step('right-drawer-threshold-mask-close-motion') await resize(1000); state = await snapshot('threshold-1000') check('source exact1000 remains split (strict less-than)', !!state.detail && !state.detail.classes.includes('app-shell__detail-slot--drawer'), true) await resize(999); state = await snapshot('threshold-999') check('999 uses right Grid drawer', state.detail?.classes.includes('_drawerItemRight_11lon_217'), true) check('drawer backdrop covers whole shell', state.backdrop && state.backdrop.width === state.shell.width && state.backdrop.height === state.shell.height && state.backdrop.x === state.shell.x, true) check('drawer starts at shell top', state.detail?.y, state.shell.y) check('drawer original transition duration/easing', [state.detail?.transitionDuration, state.detail?.transitionTimingFunction], ['0.3s', 'ease-out']) await shot('right-drawer') await click('.sidebar-next-collapse-toggle') state = await snapshot('right-closing'); check('closing drawer remains mounted while source300ms motion runs', !!state.detail, true) check('closing drawer removes open class', state.detail?.classes.includes('_gridViewDrawerItemOpen_11lon_245'), false) await pause(340); state = await snapshot('right-closed'); check('closed drawer/backdrop unmounted', [!!state.detail, !!state.backdrop], [false, false]) await openPanel(); await resize(1023); check('narrow hysteresis retained below1024', (await snapshot('hysteresis-1023')).detail?.classes.includes('--drawer'), true) await resize(1024); check('narrow hysteresis releases at1024', (await snapshot('hysteresis-1024')).detail?.classes.includes('--drawer'), false) await step('left-drawer-mobile-fullscreen') await closePanel(); await resize(640) check('source exact640 keeps sidebar (strict less-than)', !!(await snapshot('threshold-640')).sidebar, true) await resize(639); check('639 hides persistent sidebar', (await snapshot('threshold-639')).sidebar, null) await click('.titlebar__action[title="收起侧边栏"]'); await pause(340) state = await snapshot('left-drawer') check('left Grid drawer mounted and opens', state.leftDrawer?.classes.includes('_gridViewDrawerItemOpen_11lon_245'), true) check('left drawer source width280', state.leftDrawer?.width, 280) check('left drawer has full grid backdrop', state.backdrop?.width, 639) await click('[aria-label="关闭抽屉"]'); await pause(340) check('left close cleans backdrop and drawer', [!!(await snapshot('left-closed')).leftDrawer, !!snapshots['left-closed'].backdrop], [false, false]) await resize(599); await openPanel(); state = await snapshot('mobile-drawer') check('mobile drawer full grid width599', state.detail?.width, 599) check('mobile fullscreen source drawer class', state.detail?.classes.includes('_drawerItemFullScreen_11lon_238'), true) await shot('mobile-drawer') await click('[aria-label="全屏"]'); await pause(100); state = await snapshot('mobile-preview-fullscreen') check('explicit preview fullscreen covers complete main viewport', state.workbench && state.workbench.x === 0 && state.workbench.y === state.shell.y && state.workbench.width === 599 && state.workbench.height === state.shell.height, true) check('explicit fullscreen hides backdrop', !!state.backdrop, false) await click('[aria-label="退出全屏"]'); await pause(150) await resize(1336); await click('[aria-label="全屏"]'); await pause(100); state = await snapshot('wide-preview-fullscreen') check('wide fullscreen covers sidebar/main/detail rather than detail wrapper', state.workbench && state.workbench.x === 0 && state.workbench.width === 1336 && state.workbench.height === state.shell.height, true) await shot('wide-preview-fullscreen'); await click('[aria-label="退出全屏"]') await step('reduced-motion-and-rapid-toggle-cleanup') await main.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }) check('actual prefers-reduced-motion active', await read('matchMedia("(prefers-reduced-motion: reduce)").matches'), true) await closePanel(); await openPanel(); state = await snapshot('reduced-wide') check('reduced split motion no transition', state.main?.transitionDuration, '0s') await resize(599); state = await snapshot('reduced-mobile') check('reduced drawer duration zero', state.detail?.transitionDuration, '0s') await click('.sidebar-next-collapse-toggle'); await pause(10) state = await snapshot('reduced-mobile-closed'); check('reduced drawer closes without300ms mount delay', [!!state.detail, !!state.backdrop], [false, false]) await main.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }) for (const width of [1336, 599]) { await resize(width) for (let i = 0; i < 10; i++) { await click('.source-chat-header button[title="打开右侧栏"]'); await click('.sidebar-next-collapse-toggle') } await pause(450); state = await snapshot('rapid-final-' + width) check('rapid close final no detail/backdrop/fullscreen leftovers ' + width, [!!state.detail, !!state.backdrop, !!state.workbench], [false, false, false]) check('rapid close main fills available width ' + width, Math.round(state.main.width + (state.sidebar?.width || 0)), width) check('rapid close no resizing residue ' + width, await read('document.querySelector(".app-shell").hasAttribute("data-resizing")'), false) } await resize(1336); await select(a.id); await openPanel(); state = await snapshot('before-reload') const before = state.detail.width main.webContents.reload(); await wait('!!document.querySelector("[data-session-id]")', 'reload layout data'); await select(a.id); await pause(450) check('real session panel width persists through renderer reload', (await snapshot('after-reload')).detail?.width, before) await step('real-app-host-entries-and-close-tab-cleanup') for (const kind of ['browser', 'terminal']) { const id = await createHost(kind) const view = await read(`window.ycode.${kind}.get(${JSON.stringify(id)},${JSON.stringify(a.id)})`) check('App entry creates actual ' + kind + ' for current session', view.sessionId, a.id) if (kind === 'terminal') { check('App terminal uses exact session cwd', path.resolve(view.cwd), path.resolve(a.cwd)) check('App terminal contains live xterm', await read('!!document.querySelector(".native-terminal .xterm-helper-textarea")'), true) await wait(`window.ycode.terminal.get(${JSON.stringify(id)},${JSON.stringify(a.id)}).then(v=>v.buffer.includes('PS '))`, 'real PowerShell prompt') await read(`window.ycode.terminal.write(${JSON.stringify(id)},${JSON.stringify("Write-Output ('PANEL_REAL_'+'APP_PTY')\r")},${JSON.stringify(a.id)})`) await wait(`window.ycode.terminal.get(${JSON.stringify(id)},${JSON.stringify(a.id)}).then(v=>v.buffer.includes('PANEL_REAL_APP_PTY'))`, 'actual PTY command output') } await click(`[data-artifact-id="${kind}:${id}"] .artifact-tab__close`) await pause(200) check('close App tab releases actual ' + kind + ' host', await hostExists(kind, id, a.id), false) } await step('real-app-session-delete-cleans-hosts') await select(b.id); await openPanel() const browserId = await createHost('browser'), terminalId = await createHost('terminal') await select(a.id) check('switching sessions keeps background browser handle', await hostExists('browser', browserId, b.id), true) check('switching sessions keeps background PTY handle', await hostExists('terminal', terminalId, b.id), true) await select(b.id) check('session switch restores actual terminal tab', await read('document.querySelector("[data-terminal-id]")?.dataset.terminalId'), terminalId) await click(`[data-session-id="${b.id}"] .sidebar__item-action`) await wait(`!document.querySelector('[data-session-id="${b.id}"]')`, 'deleted session removed from App') await pause(300) check('deleting App session releases actual browser handle', await hostExists('browser', browserId, b.id), false) check('deleting App session releases actual PTY handle', await hostExists('terminal', terminalId, b.id), false) check('deleted session panel layout removed from persistence', await read(`!JSON.parse(localStorage.getItem('ycode.panel-layouts.v1')||'{}')[${JSON.stringify(b.id)}]`), true) check('no renderer error logs', rendererErrors, []) const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated YCode App/preload/IPC/native filesystem. No upstream calls. YCode-only CDP viewport/media/screenshot control. Source fidelity from fixed REA artifacts; not complete original screenshot parity.', sourceEvidence: ['ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb', 'ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94', 'ev_d6e6920ff4e47f73a182fac5312c5f02393366a035445d18c8a6733a8c8a11ed'], fixtures: 'Two persisted conversations; A additionally has a finished assistant with duplicate references to the real sentinel file. All runtime IPC/hosts/filesystem are production implementations.', checks, snapshots, rendererErrors } await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) console.log('PANEL_INTEGRATION_' + report.result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) console.log('PANEL_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 }, null, 2)) console.error(error); if (main) await shot('failure').catch(() => undefined); app.exit(1) }) }