Files
ycode/scripts/verify-updates-ui.cjs

561 lines
25 KiB
JavaScript

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