workspace-updates.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. /** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */
  2. import assert from 'node:assert/strict'
  3. import { registerHooks } from 'node:module'
  4. import { appendFile, readFile, writeFile } from 'node:fs/promises'
  5. import { join } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import { app, BrowserWindow, Menu } from 'electron'
  8. import updaterModule from 'electron-updater'
  9. import { createUpdateServer } from './update-server.mjs'
  10. import { fixture } from './workspace-update-adapters.mjs'
  11. import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
  12. import { resolveDesktopLocale } from '../../lib/types/locale.js'
  13. const root = process.env.DSH_WORKSPACE_UPDATE_ROOT
  14. assert.ok(root)
  15. const interactive = process.argv.includes('--interactive')
  16. const application = join(root, 'app')
  17. app.setAppPath(application)
  18. app.setPath('userData', join(root, 'electron'))
  19. const entry = pathToFileURL(join(application, 'lib/main.js')).href
  20. const adapters = new URL('./workspace-update-adapters.mjs', import.meta.url).href
  21. const hooks = registerHooks({ resolve(specifier, context, next) {
  22. if (context.parentURL === entry && ['./update-coordinator.js', './host-process.js', './update-error.js'].includes(specifier)) {
  23. return { url: adapters, shortCircuit: true }
  24. }
  25. return next(specifier, context)
  26. } })
  27. const server = await createUpdateServer()
  28. server.select('healthy', '0.1.6-nightly.1')
  29. process.env.DSH_DESKTOP_APP_ID = 'com.deepseek.qualification'
  30. process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG = JSON.stringify({ origin: new URL(server.url).origin,
  31. allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 600_000, timeoutMs: 5000, maxBackoffMs: 600_000, jitter: 0 })
  32. const config = join(root, 'app-update.yml')
  33. await writeFile(config, 'updaterCacheDirName: private-workspace-cache\n')
  34. const forbidden = () => { throw new Error('Qualification must not quit or relaunch through updater') }
  35. const updater = new updaterModule.NsisUpdater(undefined, {
  36. version: '0.1.5-rc.1', name: 'workspace-update-qualification', isPackaged: true,
  37. appUpdateConfigPath: config, userDataPath: root, baseCachePath: root,
  38. whenReady: () => app.whenReady(), quit: forbidden, relaunch: forbidden, onQuit: forbidden,
  39. })
  40. updater.logger = null
  41. updater.httpExecutor = new DesktopUpdateHttpExecutor(interactive ? 600_000 : 10_000)
  42. updater.disableDifferentialDownload = true
  43. updater.disableWebInstaller = true
  44. updater.setFeedURL({ provider: 'generic', url: server.url, channel: 'nightly' })
  45. updater.quitAndInstall = (...args) => { fixture.installations.push(args) }
  46. fixture.updater = updater
  47. async function observed(window, subject, operation) {
  48. const record = event => appendFile(join(root, 'operations.jsonl'), JSON.stringify({ at: Date.now(), subject, ...event }) + '\n')
  49. await record({ phase: 'start', url: window.webContents.getURL() })
  50. const contents = window.webContents
  51. const throttled = contents.getBackgroundThrottling()
  52. // Concurrent test windows may occlude this page; its real layout animations must still advance.
  53. contents.setBackgroundThrottling(false)
  54. let timer
  55. try {
  56. const result = await Promise.race([operation(), new Promise((_, reject) => {
  57. timer = setTimeout(() => reject(new Error(`Renderer operation timed out: ${subject}`)), 20_000)
  58. })])
  59. await record({ phase: 'done' })
  60. return result
  61. } catch (error) {
  62. const state = { destroyed: window.isDestroyed() }
  63. if (!state.destroyed) {
  64. Object.assign(state, { visible: window.isVisible(), focused: window.isFocused(), enabled: window.isEnabled() })
  65. let diagnosticTimer
  66. try {
  67. state.renderer = await Promise.race([window.webContents.executeJavaScript(`({
  68. visibility: document.visibilityState, focused: document.hasFocus(),
  69. animations: document.getAnimations().map(animation => ({ playState: animation.playState,
  70. pending: animation.pending, currentTime: animation.currentTime, playbackRate: animation.playbackRate,
  71. timing: animation.effect?.getComputedTiming() }))
  72. })`), new Promise((_, reject) => { diagnosticTimer = setTimeout(() => reject(new Error('Renderer diagnostics timed out')), 2000) })])
  73. } catch (diagnosticError) { state.diagnosticError = String(diagnosticError) }
  74. finally { clearTimeout(diagnosticTimer) }
  75. }
  76. await record({ phase: 'failed', error: String(error), state })
  77. throw error
  78. } finally {
  79. clearTimeout(timer)
  80. if (!contents.isDestroyed()) {
  81. contents.setBackgroundThrottling(throttled)
  82. assert.equal(contents.getBackgroundThrottling(), throttled)
  83. }
  84. }
  85. }
  86. async function documentReady(window, expression) {
  87. return observed(window, `document: ${expression}`, () => window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
  88. const test = () => { if (${expression}) { observer.disconnect(); clearTimeout(timer); resolve(true); } };
  89. const observer = new MutationObserver(test);
  90. const timer = setTimeout(() => { observer.disconnect(); reject(new Error(${JSON.stringify(`Document condition timed out: ${expression}`)})); }, 20000);
  91. observer.observe(document, { childList: true, subtree: true, attributes: true }); test();
  92. })`))
  93. }
  94. async function windowAt(url) {
  95. const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url)
  96. if (existing) return existing
  97. return new Promise((resolve, reject) => {
  98. const watched = []
  99. const cleanup = () => { clearTimeout(timer); app.off('browser-window-created', watch); for (const [contents, check] of watched) contents.off('did-finish-load', check) }
  100. const watch = (_event, window) => {
  101. const contents = window.webContents
  102. const check = () => { if (contents.getURL() === url) { cleanup(); resolve(window) } }
  103. watched.push([contents, check]); contents.on('did-finish-load', check); check()
  104. }
  105. const timer = setTimeout(() => { cleanup(); reject(new Error(`Window did not load ${url}`)) }, 25000)
  106. app.on('browser-window-created', watch)
  107. for (const window of BrowserWindow.getAllWindows()) watch(undefined, window)
  108. })
  109. }
  110. async function control(action) {
  111. const { url } = JSON.parse(await readFile(join(root, 'host-control.json'), 'utf8'))
  112. const response = await fetch(`${url}/${action}`, { method: 'POST', headers: { 'x-qualification-token': process.env.DSH_WORKSPACE_UPDATE_TOKEN } })
  113. assert.equal(response.status, 200, response.status === 200 ? undefined : await response.text())
  114. return response.json()
  115. }
  116. async function screenshot(window, name) {
  117. await observed(window, `screenshot layout: ${name}`, () => window.webContents.executeJavaScript(`(async () => {
  118. await new Promise(resolve => requestAnimationFrame(resolve));
  119. const finite = document.getAnimations().filter(animation => animation.effect
  120. && Number.isFinite(animation.effect.getComputedTiming().endTime));
  121. await Promise.all(finite.map(animation => animation.finished));
  122. })()`))
  123. await writeFile(join(root, name), (await observed(window, `capture: ${name}`, () => window.webContents.capturePage())).toPNG())
  124. }
  125. async function waitFor(check, subject) {
  126. const deadline = Date.now() + 20_000
  127. while (!await check()) {
  128. if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${subject}`)
  129. await new Promise(resolve => setTimeout(resolve, 50))
  130. }
  131. }
  132. async function press(window, expression) {
  133. window.focus()
  134. await documentReady(window, `(() => { const target = ${expression}; return target && !target.disabled && target.getClientRects().length > 0; })()`)
  135. const point = await observed(window, `click layout: ${expression}`, () => window.webContents.executeJavaScript(`(async () => {
  136. const target = ${expression};
  137. const movements = document.getAnimations().filter(animation => animation.effect instanceof KeyframeEffect
  138. && animation.effect.target instanceof Element && animation.effect.target.contains(target)
  139. && Number.isFinite(animation.effect.getComputedTiming().endTime));
  140. await Promise.all(movements.map(animation => animation.finished));
  141. const rect = target.getBoundingClientRect();
  142. const point = { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
  143. if (!target.contains(document.elementFromPoint(point.x, point.y))) throw new Error('Click target is obscured');
  144. return point;
  145. })()`))
  146. window.webContents.sendInputEvent({ type: 'mouseMove', ...point })
  147. window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point })
  148. window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point })
  149. }
  150. async function clickText(window, label) {
  151. await press(window, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(label)})`)
  152. await waitFor(() => window.isDestroyed(), `dialog response: ${label}`)
  153. }
  154. async function dialogWith(message) {
  155. let found
  156. await waitFor(async () => {
  157. for (const window of BrowserWindow.getAllWindows()) {
  158. if (window.webContents.getURL() !== 'dsh-app://shell/update-dialog.html') continue
  159. try {
  160. const ready = await window.webContents.executeJavaScript(`document.getElementById('title')?.textContent === ${JSON.stringify(message)} && !!document.querySelector('#actions button')`)
  161. if (ready) { found = window; return true }
  162. } catch (error) { if (!window.isDestroyed()) throw error }
  163. }
  164. return false
  165. }, `dialog: ${message}`)
  166. return found
  167. }
  168. async function qualify() {
  169. let mainWindow
  170. try {
  171. await import(entry)
  172. console.log('workspace qualification: compiled main module loaded')
  173. const applicationUrl = await fixture.ready.promise
  174. console.log('workspace qualification: Host process ready')
  175. mainWindow = await windowAt(new URL('/', applicationUrl).href)
  176. await documentReady(mainWindow, `document.querySelector('[class*="frame"]') && window.dshDesktop?.updates`)
  177. assert.equal(mainWindow.isVisible(), true, 'Workspace qualification requires a visible application window')
  178. console.log('workspace qualification: workspace document ready')
  179. const acknowledgeNotice = `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === '继续')`
  180. await press(mainWindow, acknowledgeNotice)
  181. await waitFor(async () => !await mainWindow.webContents.executeJavaScript(`!!(${acknowledgeNotice})`), 'first-run notice dismissal')
  182. console.log('workspace qualification: first-run notice dismissed')
  183. await waitFor(async () => !await fixture.host.updateTasks('inspect'), 'workspace startup API requests to settle')
  184. console.log('workspace qualification: startup API requests settled')
  185. const messages = resolveDesktopLocale(app.getLocale()).messages
  186. await screenshot(mainWindow, 'workspace.png')
  187. const menu = Menu.getApplicationMenu().items[0].submenu.items
  188. const checkMenu = menu.find(item => item.label === messages.checkUpdatesMenu)
  189. assert.ok(checkMenu)
  190. if (interactive) {
  191. const { runInteractiveUpdates } = await import('./workspace-updates-interactive.mjs')
  192. await runInteractiveUpdates({ mainWindow, server, fixture, checkMenu, control, root })
  193. return
  194. }
  195. const cases = ['real-workspace-preload-and-host']
  196. server.select('hold-check', '0.1.5-rc.1')
  197. checkMenu.click()
  198. await server.arrived()
  199. const checking = await dialogWith(messages.updateChecking)
  200. await screenshot(checking, 'checking.png')
  201. server.release()
  202. await waitFor(() => checking.isDestroyed(), 'checking dialog to close')
  203. console.log('workspace qualification: checking dialog closed')
  204. const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion()))
  205. await clickText(current, messages.updateAcknowledge)
  206. assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0)
  207. cases.push('native-menu-checking-and-current-without-download')
  208. console.log('workspace qualification: manual no-update feedback complete')
  209. server.select('healthy', '0.1.6-nightly.1')
  210. checkMenu.click()
  211. const available = await dialogWith(messages.updateAvailable)
  212. server.select('corrupt', '0.1.6-nightly.1')
  213. await clickText(available, messages.updateDownload)
  214. await waitFor(() => fixture.coordinator.state.phase === 'error', 'checksum failure')
  215. const downloadError = await dialogWith(messages.updateDownloadFailed)
  216. assert.equal(await downloadError.webContents.executeJavaScript("document.getElementById('technical-details').open"), false)
  217. assert.equal(await downloadError.webContents.executeJavaScript("document.getElementById('technical-details-content').textContent"), fixture.coordinator.state.message)
  218. await screenshot(downloadError, 'download-error.png')
  219. await clickText(downloadError, messages.updateAcknowledge)
  220. await documentReady(mainWindow, `document.querySelector('button[data-error="true"]')`)
  221. await screenshot(mainWindow, 'retry.png')
  222. await press(mainWindow, `document.querySelector('button[aria-label="收起侧边栏"]')`)
  223. await documentReady(mainWindow, `document.querySelector('button[aria-label="打开侧边栏"] [role="img"][data-error="true"]')`)
  224. await screenshot(mainWindow, 'collapsed-error.png')
  225. await press(mainWindow, `document.querySelector('button[aria-label="打开侧边栏"]')`)
  226. await documentReady(mainWindow, `document.querySelector('button[data-error="true"]')`)
  227. cases.push('download-integrity-error-and-persistent-retry')
  228. server.select('hold-download', '0.1.6-nightly.1')
  229. await press(mainWindow, `document.querySelector('button[data-error="true"]')`)
  230. await server.arrived()
  231. await documentReady(mainWindow, `document.querySelector('button[aria-disabled="true"]')`)
  232. assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
  233. await screenshot(mainWindow, 'downloading.png')
  234. server.release()
  235. const ready = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1'))
  236. assert.equal(fixture.installations.length, 0)
  237. await screenshot(ready, 'install-confirmation.png')
  238. cases.push('sidebar-retry-direct-download-and-separate-install-dialog')
  239. assert.equal((await control('queue')).queued, 1)
  240. await clickText(ready, messages.installAndRestart)
  241. await waitFor(() => fixture.coordinator.state.phase === 'error', 'task-change protection')
  242. assert.equal(fixture.coordinator.state.message, messages.updateTasksChanged)
  243. assert.equal(fixture.installations.length, 0)
  244. assert.equal((await control('status')).queued, 1)
  245. const changed = await dialogWith(messages.updateTasksChanged)
  246. await screenshot(changed, 'new-task-refusal.png')
  247. await clickText(changed, messages.updateAcknowledge)
  248. cases.push('new-task-during-idle-confirmation-refuses-install-and-preserves-work')
  249. await press(mainWindow, `document.querySelector('button[data-error="true"]')`)
  250. const warning = await dialogWith(messages.updateActiveTasks)
  251. await screenshot(warning, 'active-task-warning.png')
  252. await clickText(warning, messages.updateLater)
  253. await waitFor(() => fixture.coordinator.state.phase === 'ready', 'deferred install readiness')
  254. assert.equal((await control('status')).queued, 1)
  255. assert.equal(fixture.installations.length, 0)
  256. cases.push('task-warning-deferral-preserves-work-and-ready-package')
  257. console.log('workspace qualification: real task confirmation and deferral complete')
  258. server.policy('force')
  259. checkMenu.click()
  260. console.log('workspace qualification: mandatory check dispatched')
  261. const mandatory = await windowAt('dsh-app://shell/mandatory-update.html')
  262. console.log('workspace qualification: mandatory window loaded')
  263. await documentReady(mandatory, `document.getElementById('title')?.textContent === '需要更新'`)
  264. console.log('workspace qualification: mandatory title rendered')
  265. assert.equal(await mandatory.webContents.executeJavaScript(`document.getElementById('title').children.length`), 0)
  266. assert.equal(mandatory.isMovable(), true)
  267. assert.equal(mandatory.isResizable(), true)
  268. assert.equal(mandatory.isMaximizable(), true)
  269. const originalBounds = mandatory.getBounds()
  270. mandatory.setPosition(originalBounds.x + 20, originalBounds.y + 20)
  271. assert.notDeepEqual(mandatory.getBounds(), originalBounds)
  272. mandatory.maximize()
  273. await waitFor(() => mandatory.isMaximized(), 'mandatory maximize')
  274. mandatory.unmaximize()
  275. await waitFor(() => !mandatory.isMaximized(), 'mandatory restore')
  276. mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
  277. mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
  278. assert.equal((await control('status')).queued, 1)
  279. await screenshot(mandatory, 'mandatory-block.png')
  280. server.policy('failure')
  281. checkMenu.click()
  282. await waitFor(async () => (await mandatory.webContents.executeJavaScript('window.dshMandatoryUpdate.status()')).policy.error === 'unavailable', 'retained policy failure')
  283. assert.equal(await mandatory.webContents.executeJavaScript("document.getElementById('error').hidden"), true)
  284. assert.equal((await control('status')).queued, 1)
  285. await screenshot(mandatory, 'mandatory-retained-error.png')
  286. server.policy('clear')
  287. checkMenu.click()
  288. await waitFor(() => mandatory.isDestroyed(), 'fresh no-force response to clear the modal')
  289. assert.equal(mainWindow.isEnabled(), true)
  290. assert.equal(fixture.installations.length, 0)
  291. cases.push('mandatory-main-entry-block-retains-real-work-through-failure-and-clearance')
  292. await press(mainWindow, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(messages.installAndRestart)})`)
  293. const stop = await dialogWith(messages.updateActiveTasks)
  294. const stoppedHost = fixture.host
  295. await control('hold-shutdown')
  296. await clickText(stop, messages.updateStopTasks)
  297. await documentReady(mainWindow, `[...document.querySelectorAll('button')].some(button => button.textContent.trim() === ${JSON.stringify(messages.updateInstalling)})`)
  298. assert.equal(await mainWindow.webContents.executeJavaScript("document.body.innerText.includes('重新连接中')"), false)
  299. await screenshot(mainWindow, 'installing-before-host-exit.png')
  300. await waitFor(() => fixture.coordinator.state.phase === 'error', 'unclean Host stop rejection')
  301. assert.equal(fixture.coordinator.state.message, messages.updateStopFailed)
  302. assert.match(fixture.coordinator.state.technicalDetails, /did not complete graceful task teardown/)
  303. assert.equal(fixture.installations.length, 0)
  304. const failedStop = await dialogWith(fixture.coordinator.state.message)
  305. await screenshot(failedStop, 'stop-failure.png')
  306. cases.push('actual-host-teardown-timeout-refuses-installer')
  307. await clickText(failedStop, messages.updateAcknowledge)
  308. await waitFor(() => fixture.host !== stoppedHost && fixture.readyHosts.has(fixture.host), 'replacement Host after non-graceful shutdown')
  309. await waitFor(async () => !await fixture.host.updateTasks('inspect'), 'replacement Host ready without active work')
  310. await press(mainWindow, `document.querySelector('button[data-error="true"]')`)
  311. const retryInstall = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1'))
  312. assert.equal(fixture.installations.length, 0)
  313. await screenshot(retryInstall, 'recovered-install-confirmation.png')
  314. await press(retryInstall, `document.getElementById('close')`)
  315. await waitFor(() => retryInstall.isDestroyed(), 'recovered confirmation dismissal')
  316. await waitFor(() => fixture.coordinator.state.phase === 'ready', 'recovered update ready for explicit retry')
  317. assert.equal(fixture.installations.length, 0)
  318. cases.push('non-graceful-stop-restores-host-and-requires-fresh-install-confirmation')
  319. server.policy('force')
  320. checkMenu.click()
  321. const forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html')
  322. await press(forcedRecovery, `document.getElementById('update')`)
  323. await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`)
  324. assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
  325. const forcedHost = fixture.host
  326. await control('hold-shutdown')
  327. await press(forcedRecovery, `document.getElementById('update')`)
  328. await waitFor(() => fixture.coordinator.state.phase === 'error', 'mandatory Host stop rejection')
  329. await documentReady(forcedRecovery, `document.getElementById('error')?.textContent === ${JSON.stringify(messages.updateStopFailed)}`)
  330. await screenshot(forcedRecovery, 'mandatory-stop-failure.png')
  331. await waitFor(() => fixture.host !== forcedHost && fixture.readyHosts.has(fixture.host), 'mandatory replacement Host readiness')
  332. assert.equal(mainWindow.isEnabled(), false)
  333. assert.equal(fixture.installations.length, 0)
  334. await control('queue')
  335. await press(forcedRecovery, `document.getElementById('update')`)
  336. await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.updateStopTasks)}`)
  337. await screenshot(forcedRecovery, 'mandatory-recovered-confirmation.png')
  338. await press(forcedRecovery, `document.getElementById('later')`)
  339. await waitFor(() => fixture.coordinator.state.phase === 'ready', 'mandatory deferred retry readiness')
  340. assert.equal(forcedRecovery.isDestroyed(), false)
  341. assert.equal(mainWindow.isEnabled(), false)
  342. assert.equal(fixture.installations.length, 0)
  343. server.policy('clear')
  344. checkMenu.click()
  345. await waitFor(() => forcedRecovery.isDestroyed(), 'mandatory recovery policy clearance')
  346. cases.push('mandatory-stop-recovery-preserves-block-and-requires-fresh-install-confirmation')
  347. await writeFile(join(root, 'result.json'), JSON.stringify({ realElectron: true, realHostProcess: true,
  348. realPreload: true, compiledMainEntry: true, installerExecuted: false, cases,
  349. menu: menu.map(item => item.label), phases: fixture.states.map(state => state.phase) }, null, 2) + '\n')
  350. } catch (error) {
  351. await writeFile(join(root, 'failure.txt'), String(error.stack ?? error))
  352. await writeFile(join(root, 'failure-update-state.json'), JSON.stringify(fixture.coordinator?.state, null, 2))
  353. await writeFile(join(root, 'task-queries.json'), JSON.stringify(fixture.taskQueries, null, 2))
  354. try { await writeFile(join(root, 'failure-host-state.json'), JSON.stringify(await control('status'), null, 2)) }
  355. catch (diagnosticError) { await writeFile(join(root, 'failure-host-state.txt'), String(diagnosticError)) }
  356. const windows = []
  357. for (const window of BrowserWindow.getAllWindows()) {
  358. const state = { url: window.webContents.getURL(), visible: window.isVisible(), title: window.getTitle() }
  359. try {
  360. state.document = await observed(window, 'failure window content', () => window.webContents.executeJavaScript(`({
  361. title: document.getElementById('title')?.textContent,
  362. buttons: [...document.querySelectorAll('button')].map(button => button.textContent),
  363. })`))
  364. } catch (diagnosticError) { state.error = String(diagnosticError) }
  365. windows.push(state)
  366. }
  367. await writeFile(join(root, 'failure-windows.json'), JSON.stringify(windows, null, 2))
  368. if (mainWindow && !mainWindow.isDestroyed()) {
  369. try {
  370. await writeFile(join(root, 'failure-buttons.json'), JSON.stringify(await observed(mainWindow, 'failure buttons', () =>
  371. mainWindow.webContents.executeJavaScript(
  372. `[...document.querySelectorAll('button')].map(button => ({ text: button.textContent, aria: button.getAttribute('aria-label') }))`)), null, 2))
  373. await screenshot(mainWindow, 'failure-workspace.png')
  374. } catch (diagnosticError) {
  375. await writeFile(join(root, 'failure-artifact-error.txt'), String(diagnosticError.stack ?? diagnosticError))
  376. }
  377. }
  378. throw error
  379. } finally {
  380. hooks.deregister()
  381. await server.close()
  382. app.quit()
  383. }
  384. }
  385. const fail = error => { console.error(error); app.exit(1) }
  386. process.on('uncaughtException', fail)
  387. process.on('unhandledRejection', fail)
  388. void qualify().catch(fail)