// Real isolated YCode App/preload/renderer verification for the update UI. // Production out/main/preload are loaded unchanged. Only the harness replaces updates:* IPC handlers // so checking, download progress, failure/retry, dismissal and install can be exercised deterministically. 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/updates-ui-')) const data = path.join(directory, 'data') const home = path.join(directory, 'home') await Promise.all([fs.mkdir(data), fs.mkdir(home)]) const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '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('UPDATES_UI_')) process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes if (bytes.toString().includes('UPDATES_UI_')) process.stderr.write(bytes) }) const timer = setTimeout(() => child.kill(), 300000) 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, ipcMain } = require('electron') const { pathToFileURL } = require('node:url') const packageVersion = require('../package.json').version const directory = process.env.YCODE_QA_DIRECTORY assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('updates-ui-')) 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 settings let phase = 'boot' const checks = [] const snapshots = {} const rendererErrors = [] const actionCounts = { check: 0, download: 0, install: 0, dismiss: 0, downloadPage: 0, log: 0 } let updateState = { status: 'idle', currentVersion: packageVersion, lastExplicit: false } const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) function broadcastUpdateState() { for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send('updates:state', { ...updateState }) } } function setUpdateState(patch, replace = false) { updateState = { ...(replace ? { currentVersion: updateState.currentVersion } : updateState), ...patch, lastChecked: new Date().toISOString() } broadcastUpdateState() return { ...updateState } } const nativeHandle = ipcMain.handle.bind(ipcMain) const absorbedUpdateHandlers = [] ipcMain.handle = (channel, listener) => { if (channel.startsWith('updates:')) { absorbedUpdateHandlers.push(channel) return } return nativeHandle(channel, listener) } nativeHandle('updates:get-state', () => ({ ...updateState })) nativeHandle('updates:check', () => { actionCounts.check += 1 return setUpdateState({ status: 'checking', error: undefined, lastExplicit: true }) }) nativeHandle('updates:download', () => { actionCounts.download += 1 return setUpdateState({ status: 'downloading', progress: 37, error: undefined, lastExplicit: true }) }) nativeHandle('updates:install', () => { actionCounts.install += 1 return setUpdateState({ status: 'installing', error: undefined, lastExplicit: true }) }) nativeHandle('updates:dismiss', () => { actionCounts.dismiss += 1 return setUpdateState({ dismissedVersion: updateState.version, status: updateState.status === 'installing' ? 'ready' : updateState.status }) }) nativeHandle('updates:open-download-page', () => { actionCounts.downloadPage += 1 }) nativeHandle('updates:open-log', () => { actionCounts.log += 1 }) 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 += 1) { const value = await read(source, win) if (value) return value await pause(35) } 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 }) } function observe(win) { win.webContents.on('console-message', (_event, level, message) => { if (level >= 3) rendererErrors.push({ windowId: win.id, message }) }) } async function shot(name, win = main) { const image = await win.webContents.capturePage() await fs.writeFile(path.join(directory, name + '.png'), image.toPNG()) } async function click(selector, win = main) { const clicked = await read(`(()=>{ const node=document.querySelector(${JSON.stringify(selector)}); if(!node)return false; node.click(); return true })()`, win) assert.equal(clicked, true, 'click target: ' + selector) await pause(100) } async function openSettings(section) { await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`) for (let index = 0; index < 240; index += 1) { settings = BrowserWindow.getAllWindows().find(win => win !== main) if (settings && !settings.webContents.isLoading()) break await pause(25) } assert.ok(settings, 'settings window') await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings) await wait('!!document.querySelector(".help-feedback-panel")', 'about panel', settings) await pause(300) } async function setTheme(theme) { await read(`(()=>{ localStorage.setItem('ycode:theme',${JSON.stringify(theme)}); window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}})); return true })()`) await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'main theme ' + theme) if (settings && !settings.isDestroyed()) { await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'settings theme ' + theme, settings) } } const cardSource = `(()=>{ const card=document.querySelector('.update-sidebar-card'); const compact=document.querySelector('.update-sidebar-card-collapsed'); if(!card&&!compact)return null; const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]}; const style=node=>getComputedStyle(node); const progress=card?.querySelector('.update-sidebar-card__progress'); const primary=card?.querySelector('[data-update-action]'); return { status:card?.getAttribute('data-status')??null, compact:!!compact, card:card?{box:box(card),background:style(card).backgroundColor,border:style(card).borderTopColor, title:card.querySelector('.update-sidebar-card__title')?.textContent??'', description:card.querySelector('.update-sidebar-card__description')?.textContent??'', primary:primary?{text:primary.textContent.trim(),kind:primary.getAttribute('data-update-action'),disabled:primary.disabled}:null, progress:progress?{value:progress.getAttribute('aria-valuenow'),width:progress.getBoundingClientRect().width}:null, manual:!!card.querySelector('[data-update-manual]'),log:!!card.querySelector('[data-update-log]')}:null, accountCardPresent:!!document.querySelector('.conversation-list-footer .update-sidebar-card-host') } })()` const accountSource = `(()=>{ const row=document.querySelector('.ycode-account-menu [data-update-action]'); if(!row)return null; return {status:row.getAttribute('data-update-status'),kind:row.getAttribute('data-update-action'), label:row.querySelector('.user-menu-item-label')?.textContent??'', value:row.querySelector('.user-menu-item-value')?.textContent??'', ariaDisabled:row.getAttribute('aria-disabled')} })()` const aboutSource = `(()=>{ const row=document.querySelector('#settings-about-version'); const button=row?.querySelector('[data-update-action]'); const feedback=document.querySelector('.update-ui-feedback'); if(!row||!button)return null; return {status:button.getAttribute('data-update-status'),kind:button.getAttribute('data-update-action'), label:button.textContent.trim(),disabled:button.disabled,feedback:feedback?.textContent.replace(/\\s+/g,' ').trim()??'', currentVersion:row.querySelector('.help-feedback-panel__card-value')?.textContent??''} })()` ;(async () => { await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) await app.whenReady() for (let index = 0; index < 240; index += 1) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') if (main && !main.webContents.isLoading()) break await pause(25) } assert.ok(main, 'main window') observe(main) main.setSize(1336, 902) main.showInactive() await wait('!!window.ycode&&!!document.querySelector(".conversation-list")', 'main renderer') updateState.currentVersion = packageVersion broadcastUpdateState() await pause(300) await step('ready-state') setUpdateState({ status: 'ready', version: '0.0.3', notes: '改进更新卡片、失败重试与安装确认。', progress: undefined, error: undefined, dismissedVersion: undefined }, true) await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready update card') snapshots.ready = await read(cardSource) check('ready update card is mounted permanently above the account trigger', [snapshots.ready.status, snapshots.ready.accountCardPresent, snapshots.ready.compact], ['ready', true, false]) check('ready card offers restart and shows the target version', [snapshots.ready.card.primary.text, /v0\.0\.3/.test(snapshots.ready.card.title)], ['重启升级', true]) await step('account-sync-ready') await click('.user-menu-trigger--workbuddy') await wait('!!document.querySelector(".ycode-account-menu")', 'account menu') snapshots.accountReady = await read(accountSource) check('account menu shares the ready state and remains actionable', [snapshots.accountReady.status, snapshots.accountReady.kind, snapshots.accountReady.label, snapshots.accountReady.ariaDisabled], ['ready', 'install', '重启升级', 'false']) await shot('updates-main-light-ready') await click('.ycode-account-menu [data-update-action="install"]') check('account menu install action reaches the updates IPC bridge', actionCounts.install, 1) await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='installing'`, 'account installing state') await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`) await pause(100) await shot('updates-main-light-ready-card') await step('dismiss-and-reveal') setUpdateState({ status: 'ready', version: '0.0.3', progress: undefined, error: undefined, dismissedVersion: undefined }) await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"] [data-update-dismiss]')`, 'ready card dismiss') await click('[data-update-dismiss]') await wait('!!document.querySelector(".update-sidebar-card-collapsed")', 'collapsed update entry') check('closing the card dismisses this version and keeps a compact update entry', [actionCounts.dismiss, await read(`!document.querySelector('.update-sidebar-card')`)], [1, true]) await click('[data-update-reveal]') await wait('!!document.querySelector(".update-sidebar-card")', 'revealed collapsed update card') check('the compact update entry can reveal the dismissed version card again', await read(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`), true) await step('error-and-manual-links') setUpdateState({ status: 'error', version: '0.0.4', progress: undefined, error: '模拟更新检查失败', dismissedVersion: undefined }) await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'error update card') snapshots.error = await read(cardSource) check('error state exposes retry, manual download and update log', [snapshots.error.card.primary.text, snapshots.error.card.manual, snapshots.error.card.log], ['重试', true, true]) await click('[data-update-manual]') await click('[data-update-log]') check('manual download and update log actions reach the updates IPC bridge', [actionCounts.downloadPage, actionCounts.log], [1, 1]) await click('.update-sidebar-card [data-update-action="check"]') await wait(`!!document.querySelector('.update-sidebar-card')===false`, 'checking hides the non-actionable card') check('retry invokes the real check IPC action', actionCounts.check, 1) setUpdateState({ status: 'ready', version: '0.0.4', error: undefined }) await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready after retry') await step('settings-ready') await openSettings('helpFeedback') observe(settings) snapshots.aboutReady = await read(aboutSource, settings) check('settings About shows the shared ready state', [snapshots.aboutReady.status, snapshots.aboutReady.kind, snapshots.aboutReady.label, /新版本 v0\.0\.4 已就绪/.test(snapshots.aboutReady.feedback)], ['ready', 'install', '重启升级', true]) await shot('updates-settings-light-ready', settings) await step('settings-download-sync') setUpdateState({ status: 'available', version: '0.0.5', progress: undefined, error: undefined, dismissedVersion: undefined }) await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='available'`, 'settings available state', settings) await wait(`!!document.querySelector('.update-sidebar-card[data-status="available"]')`, 'main available card') await click('#settings-about-version [data-update-action="download"]', settings) await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.includes('下载中 37%')`, 'settings download progress', settings) await wait(`document.querySelector('.update-sidebar-card[data-status="downloading"]')?.querySelector('.update-sidebar-card__progress')?.getAttribute('aria-valuenow')==='37'`, 'main download progress') snapshots.downloading = { main: await read(cardSource), settings: await read(aboutSource, settings) } check('download initiated in settings synchronizes to the sidebar card', [actionCounts.download, snapshots.downloading.main.status, snapshots.downloading.main.card.progress.value, snapshots.downloading.settings.label], [1, 'downloading', '37', '下载中 37%']) setUpdateState({ status: 'ready', version: '0.0.5', progress: 100 }) await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()==='重启升级'`, 'settings ready after download', settings) await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'main ready after download') check('download completion synchronizes settings and sidebar', await Promise.all([ read(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()`, settings), read(`document.querySelector('.update-sidebar-card__primary')?.textContent.trim()`) ]), ['重启升级', '重启升级']) await step('dark') await setTheme('dark') snapshots.dark = { main: await read(cardSource), settings: await read(aboutSource, settings) } check('update card remains rendered with shared dark theme text and surface', [snapshots.dark.main.status, snapshots.dark.main.card.background !== 'rgba(0, 0, 0, 0)'], ['ready', true]) await shot('updates-main-dark') await shot('updates-settings-dark', settings) await step('narrow-light') await setTheme('light') main.setSize(940, 800) await pause(300) snapshots.narrow = await read(`(()=>{ const card=document.querySelector('.update-sidebar-card'); const sidebar=document.querySelector('.conversation-list'); if(!card||!sidebar)return null; const c=card.getBoundingClientRect(),s=sidebar.getBoundingClientRect(); return {viewport:innerWidth,card:[c.x,c.y,c.width,c.height],sidebar:[s.x,s.y,s.width,s.height], horizontalOverflow:document.documentElement.scrollWidth>innerWidth, title:card.querySelector('.update-sidebar-card__title')?.textContent??''} })()`) check('narrow window keeps the update card inside the sidebar without horizontal overflow', [snapshots.narrow.horizontalOverflow, snapshots.narrow.card[2] <= snapshots.narrow.sidebar[2] + 1], [false, true]) await shot('updates-main-narrow') await step('up-to-date') setUpdateState({ status: 'up-to-date', version: undefined, progress: undefined, error: undefined, dismissedVersion: undefined }, true) await wait('!document.querySelector(".update-sidebar-card")', 'up-to-date hides sidebar card') await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'settings up-to-date', settings) await click('.user-menu-trigger--workbuddy') await wait('!!document.querySelector(".ycode-account-menu")', 'account menu up-to-date') snapshots.upToDate = { mainCard: await read(`!document.querySelector('.update-sidebar-card')`), account: await read(accountSource), settings: await read(aboutSource, settings) } check('up-to-date state hides the sidebar card and synchronizes account/settings', [snapshots.upToDate.mainCard, snapshots.upToDate.account.status, snapshots.upToDate.account.value, /已是最新版本/.test(snapshots.upToDate.settings.feedback)], [true, 'up-to-date', '已是最新', true]) await step('unsupported') setUpdateState({ status: 'checking', version: undefined, progress: undefined, error: undefined, dismissedVersion: undefined, unfinishedVersion: undefined }, true) await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'background check clears explicit intent') setUpdateState({ status: 'unsupported', version: undefined, progress: undefined, error: '当前平台不支持自动更新', dismissedVersion: undefined, unfinishedVersion: undefined }, true) await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='unsupported'`, 'unsupported account row') snapshots.unsupported = { main: await read(cardSource), account: await read(accountSource), settings: await read(aboutSource, settings) } check('background unsupported state stays out of the sidebar while account/settings remain honest', [snapshots.unsupported.main, snapshots.unsupported.account.label, snapshots.unsupported.account.value, snapshots.unsupported.account.kind, /当前平台不支持自动更新/.test(snapshots.unsupported.settings.feedback)], [null, '手动下载', '需手动下载', 'manual', true]) await step('background-error-quiet') setUpdateState({ status: 'error', version: undefined, progress: undefined, error: '后台检查失败', dismissedVersion: undefined, unfinishedVersion: undefined }, true) await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='error'`, 'quiet background error') await pause(150) check('background error with no released version does not open a sidebar card', [await read(`!document.querySelector('.update-sidebar-card')`), (await read(accountSource)).label], [true, '重试']) await click('.ycode-account-menu [data-update-action="check"]') await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'explicit retry checking') setUpdateState({ status: 'error', error: '显式检查失败' }, true) await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'explicit error card') check('explicit retry failure is allowed to surface in the sidebar', [actionCounts.check, await read(`document.querySelector('.update-sidebar-card__description')?.textContent`)], [2, '显式检查失败']) await step('unfinished-version') setUpdateState({ status: 'up-to-date', version: undefined, progress: undefined, error: undefined, dismissedVersion: undefined, unfinishedVersion: '0.0.6' }, true) await wait(`!!document.querySelector('.update-sidebar-card[data-unfinished="true"]')`, 'unfinished update card') await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'unfinished account row') snapshots.unfinished = { main: await read(cardSource), account: await read(accountSource), settings: await read(aboutSource, settings) } check('unfinishedVersion survives an otherwise latest state as an actionable warning', [snapshots.unfinished.main.card.title, snapshots.unfinished.main.card.primary.text, snapshots.unfinished.account.label, snapshots.unfinished.account.value, /上次升级至 v0\.0\.6 未完成/.test(snapshots.unfinished.settings.feedback)], ['上次升级未完成', '重试更新', '重试更新', '上次升级未完成', true]) await click('[data-update-log]') check('unfinished update warning opens the update log', actionCounts.log, 2) await step('errors') check('no renderer error logs', rendererErrors, []) const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated YCode main/preload/renderer and settings window at 1336x902, 940x800 narrow, 900x680 settings; deterministic updates IPC injection; ready/downloading/error/up-to-date/unsupported/unfinished states, retry, dismissal, manual/log links, cross-window synchronization, light/dark screenshots.', limitations: [ 'This verifies renderer interaction and IPC call shape, not a real package download, signature verification, NSIS install, or restart.', 'The harness intercepts updates:install before the production native confirmation dialog, so confirmation copy and dialog behavior remain a main-process acceptance item.', 'Static REA evidence does not include final visual measurements for every WorkBuddy update-card state; screenshots are visual review artifacts, not pixel-parity claims.' ], injectedUpdateChannels: [ 'updates:get-state', 'updates:check', 'updates:download', 'updates:install', 'updates:dismiss', 'updates:open-download-page', 'updates:open-log' ], absorbedProductionUpdateHandlers: absorbedUpdateHandlers, actionCounts, snapshots, checks, rendererErrors } await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) console.log('UPDATES_UI_' + report.result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) { console.log('UPDATES_UI_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, actionCounts, checks, snapshots, rendererErrors }, null, 2)) console.error(error) app.exit(1) }) }