update-dialogs.mjs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. /** Real isolated update dialogs; installer handoff is recorded by the caller's inert updater. */
  2. import assert from 'node:assert/strict'
  3. import { once } from 'node:events'
  4. import { readFile, writeFile } from 'node:fs/promises'
  5. import { join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import { app, BrowserWindow, protocol } from 'electron'
  8. import { DesktopUpdateDialog } from '../../lib/types/update-dialog.js'
  9. import { formatDesktopMessage, resolveDesktopLocale } from '../../lib/types/locale.js'
  10. async function rendered(window) {
  11. await window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
  12. const observer = new MutationObserver(check);
  13. const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Dialog did not render')); }, 10000);
  14. function check() { if (document.querySelector('main:not([hidden])')) { observer.disconnect(); clearTimeout(deadline); resolve(); } }
  15. observer.observe(document, { subtree: true, attributes: true }); check();
  16. })`)
  17. }
  18. /** Exercise cancellation and explicit installation through the production preload and renderer. */
  19. export async function qualifyUpdateDialogs(root, fixture) {
  20. const locale = resolveDesktopLocale('zh-CN')
  21. const messages = locale.messages
  22. const screenshots = []
  23. protocol.handle('dsh-app', async request => {
  24. const name = new URL(request.url).pathname.slice(1)
  25. assert.ok(['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name))
  26. const mime = name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css'
  27. : name.endsWith('.svg') ? 'image/svg+xml' : 'text/html'
  28. return new Response(await readFile(new URL(`../../renderer/${name}`, import.meta.url)), { headers: { 'content-type': mime } })
  29. })
  30. const parent = new BrowserWindow({ show: true, width: 900, height: 650 })
  31. const dialogs = new DesktopUpdateDialog(fileURLToPath(new URL('../../lib/preload-update-dialog.cjs', import.meta.url)), locale)
  32. try {
  33. await parent.loadURL('data:text/html;charset=utf-8,<title>Update dialog qualification</title><h1>Local updater</h1>')
  34. const f = await fixture()
  35. const available = await f.coordinator.check()
  36. await f.coordinator.download(available.version)
  37. assert.equal(f.installations.length, 0)
  38. for (const active of [false, true]) {
  39. const options = {
  40. title: messages.updateTitle,
  41. message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: available.version }),
  42. detail: active ? messages.updateActiveTasksDetail : messages.updateDownloadedDetail,
  43. buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], cancelId: 1,
  44. }
  45. f.restart(async () => (await dialogs.show(parent, options)).response === 0)
  46. const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
  47. const pending = f.coordinator.install(available.version)
  48. const [, window] = await created
  49. await once(window.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
  50. await rendered(window)
  51. const view = await window.webContents.executeJavaScript(`({
  52. title: document.getElementById('title').textContent,
  53. detail: document.getElementById('detail').textContent,
  54. buttons: [...document.querySelectorAll('#actions button')].map(button => button.textContent),
  55. width: document.querySelector('main').getBoundingClientRect().width,
  56. radius: getComputedStyle(document.querySelector('main')).borderRadius,
  57. primary: getComputedStyle(document.querySelector('.primary')).backgroundColor,
  58. focused: document.activeElement.id, isolated: typeof window.require === 'undefined',
  59. })`)
  60. assert.deepEqual(view, { title: options.message, detail: options.detail, buttons: options.buttons,
  61. width: 380, radius: '24px', primary: 'rgb(15, 17, 21)', focused: 'dialog', isolated: true })
  62. assert.equal(await parent.webContents.executeJavaScript('getComputedStyle(document.body).filter'), 'blur(2px)')
  63. assert.equal(f.installations.length, 0)
  64. assert.equal(await window.webContents.executeJavaScript(`
  65. document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }));
  66. document.activeElement.id`), 'close')
  67. assert.equal(await window.webContents.executeJavaScript(`
  68. document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true }));
  69. document.activeElement.textContent`), options.buttons.at(-1))
  70. await window.webContents.executeJavaScript("document.getElementById('dialog').focus()")
  71. const name = active ? 'update-active-tasks.png' : 'update-ready.png'
  72. await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
  73. screenshots.push(name)
  74. if (active) {
  75. await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined")
  76. } else {
  77. window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
  78. }
  79. const result = await pending
  80. assert.equal(window.isDestroyed(), true)
  81. assert.equal(result.phase, active ? 'installing' : 'ready')
  82. assert.equal(f.installations.length, active ? 1 : 0)
  83. }
  84. assert.deepEqual(f.installations, [[true, true]])
  85. const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
  86. const diagnostics = 'exit 0; shutdown acknowledged false\n' + 'at internal/diagnostic/path\n'.repeat(1000)
  87. const pending = dialogs.show(parent, { title: messages.updateFailedTitle, message: messages.updateStopFailed,
  88. technicalDetails: diagnostics })
  89. const [, errorWindow] = await created
  90. await once(errorWindow.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
  91. await rendered(errorWindow)
  92. assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details').open"), false)
  93. for (const expanded of [false, true]) {
  94. if (expanded) {
  95. assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
  96. assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details-content').textContent"), diagnostics)
  97. assert.equal(await errorWindow.webContents.executeJavaScript(`(() => {
  98. const detail = document.getElementById('technical-details-content');
  99. const button = document.querySelector('.primary').getBoundingClientRect();
  100. return detail.scrollHeight > detail.clientHeight && detail.getBoundingClientRect().height <= 180
  101. && button.top >= 0 && button.bottom <= innerHeight;
  102. })()`), true)
  103. }
  104. const name = expanded ? 'update-error-expanded.png' : 'update-error-collapsed.png'
  105. await writeFile(join(root, name), (await errorWindow.webContents.capturePage()).toPNG())
  106. screenshots.push(name)
  107. }
  108. assert.deepEqual(f.installations, [[true, true]])
  109. errorWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
  110. assert.equal((await pending).response, 0)
  111. return screenshots
  112. } finally {
  113. dialogs.dispose()
  114. parent.destroy()
  115. protocol.unhandle('dsh-app')
  116. }
  117. }