windows-upgrade.test.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { createServer } from 'node:http'
  4. import { execFile } from 'node:child_process'
  5. import { promisify } from 'node:util'
  6. import { mkdtemp, readFile, writeFile, mkdir, rm, readdir, realpath, copyFile, access } from 'node:fs/promises'
  7. import { tmpdir, homedir } from 'node:os'
  8. import { join, resolve, basename } from 'node:path'
  9. import { randomUUID } from 'node:crypto'
  10. import { _electron as electron, expect } from '@playwright/test'
  11. import { powershellArguments } from './windows-shell.mjs'
  12. const execute = promisify(execFile)
  13. test('installed NSIS client rejects a corrupt update, upgrades and preserves settings and cookies', {
  14. skip: process.platform !== 'win32' || process.env.DESKTOP_TEST_UPGRADE !== '1', timeout: 600000,
  15. }, async () => {
  16. const root = resolve(import.meta.dirname, '..'), output = join(root, 'desktop-release/windows-x64')
  17. const evidence = join(root, 'test-results/windows-client'), sandbox = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-upgrade-')))
  18. // NSIS relaunches through Explorer, which correctly uses the user's normal
  19. // environment instead of inheriting Playwright's fixture-only variables.
  20. // This test may touch the normal profile only on a fresh disposable CI user.
  21. assert.equal(process.env.GITHUB_ACTIONS, 'true', '安装升级验收只允许在一次性 GitHub Actions 用户下执行')
  22. assert.ok(!process.env.YAOYAO_HOME && !process.env.HERMES_YAOYAO_HOME && !process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME)
  23. const home = join(homedir(), '.yaoyao'), installed = join(sandbox, 'app'), feed = join(sandbox, 'feed')
  24. assert.equal(await access(home).then(() => true).catch(() => false), false, '拒绝覆盖已有用户数据')
  25. const executablePath = join(installed, 'Yaoyao.exe'), cacheName = 'yaoyao-upgrade-' + randomUUID()
  26. const originalManifest = await readFile(join(root, '.desktop-build/release.json'), 'utf8')
  27. const originalPackage = await readFile(join(root, '.desktop-build/shell/package.json'), 'utf8')
  28. const version = JSON.parse(originalPackage).version, nextVersion = version.replace(/\d+$/, value => String(Number(value) + 1))
  29. let application, logs, userData, corrupt = true, requireCookie = false, restored = 0
  30. const server = createServer(async (req, res) => {
  31. try {
  32. const path = new URL(req.url, 'http://localhost').pathname
  33. if (path === '/api/app/desktop-session') {
  34. res.setHeader('Set-Cookie', 'hermes_yaoyao_session=fixture; Path=/; HttpOnly; Max-Age=31536000')
  35. res.setHeader('Content-Type', 'application/json'); res.end('{}'); return
  36. }
  37. if (path === '/api/app/bootstrap') {
  38. const authenticated = !requireCookie || String(req.headers.cookie).includes('hermes_yaoyao_session=fixture')
  39. if (requireCookie && authenticated) restored++
  40. res.setHeader('content-type', 'application/json')
  41. res.end(JSON.stringify({ authenticated, csrfToken: 'fixture', user: { role: 'member' }, serverKind: 'yaoyao-web' })); return
  42. }
  43. if (path.startsWith('/feed/')) {
  44. const name = decodeURIComponent(path.slice(6))
  45. if (basename(name) !== name) { res.writeHead(404).end(); return }
  46. const bytes = corrupt && name.endsWith('.exe') ? Buffer.from('damaged-installer') : await readFile(join(feed, name))
  47. res.setHeader('content-length', bytes.length); res.end(bytes); return
  48. }
  49. res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>升级验收</title><h1>远程工作区</h1>')
  50. } catch { res.writeHead(404).end() }
  51. })
  52. await new Promise(done => server.listen(0, '127.0.0.1', done))
  53. const origin = `http://127.0.0.1:${server.address().port}`
  54. const shell = join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe')
  55. const stopInstalled = async () => {
  56. // Only stop this test's executable, never another Yaoyao installation.
  57. const literal = executablePath.replaceAll("'", "''")
  58. await execute(shell, powershellArguments(`Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq '${literal}' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`), { windowsHide: true }).catch(() => {})
  59. }
  60. try {
  61. await mkdir(home, { recursive: true }); await mkdir(evidence, { recursive: true })
  62. const { build, Platform, Arch } = await import('electron-builder')
  63. try {
  64. await writeFile(join(root, '.desktop-build/release.json'), JSON.stringify({ ...JSON.parse(originalManifest), webVersion: nextVersion }))
  65. await writeFile(join(root, '.desktop-build/shell/package.json'), JSON.stringify({ ...JSON.parse(originalPackage), version: nextVersion }))
  66. process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
  67. await build({ projectDir: root, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
  68. config: { directories: { output: feed }, forceCodeSigning: false, win: { signExecutable: false } } })
  69. } finally {
  70. await writeFile(join(root, '.desktop-build/release.json'), originalManifest)
  71. await writeFile(join(root, '.desktop-build/shell/package.json'), originalPackage)
  72. }
  73. await execute(join(output, `Yaoyao-${version}-win-x64-setup.exe`), ['/S', `/D=${installed}`], { timeout: 120000 })
  74. console.log('NSIS 安装完成,开始测试隔离更新源')
  75. const preferences = { startupChoice: 'remote', remoteServer: origin, backgroundAtLogin: true }
  76. await writeFile(join(home, 'desktop-preferences.json'), JSON.stringify(preferences))
  77. // This installed test copy alone uses the isolated HTTP update source.
  78. await writeFile(join(installed, 'resources/app-update.yml'), JSON.stringify({ provider: 'generic', url: origin + '/feed/', updaterCacheDirName: cacheName }))
  79. const launch = () => electron.launch({ executablePath, env: { ...process.env } })
  80. application = await launch(); let page = await application.firstWindow(); await page.waitForURL(origin + '/**')
  81. logs = await application.evaluate(({ app }) => app.getPath('logs'))
  82. userData = await application.evaluate(({ app }) => app.getPath('userData'))
  83. assert.equal(await application.evaluate(({ app }) => app.getVersion()), version)
  84. await application.evaluate(async ({ session, safeStorage }, { origin, home }) => {
  85. await session.defaultSession.cookies.set({ url: origin, name: 'session', value: 'upgrade-fixture', expirationDate: Date.now() / 1000 + 3600 })
  86. await session.defaultSession.cookies.flushStore()
  87. const fs = process.getBuiltinModule('node:fs')
  88. fs.writeFileSync(home + '/upgrade-token.enc', await safeStorage.encryptStringAsync('upgrade-device-identity'))
  89. }, { origin, home })
  90. requireCookie = true
  91. await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
  92. assert.equal(application.windows().length, 1)
  93. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.available)), { timeout: 30000 }).toBe(true)
  94. await page.evaluate(() => window.yaoyaoDesktop.updateAction('download'))
  95. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.phase)), { timeout: 120000 }).toBe('failed')
  96. console.log('已拒绝损坏安装包,重试完整下载')
  97. corrupt = false
  98. await page.evaluate(() => window.yaoyaoDesktop.updateAction('check'))
  99. await page.evaluate(() => window.yaoyaoDesktop.updateAction('download'))
  100. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.phase)), { timeout: 120000 }).toBe('ready')
  101. await page.screenshot({ path: join(evidence, 'upgrade-ready.png') })
  102. console.log('完整更新包已就绪,开始重启替换')
  103. const closed = application.waitForEvent('close')
  104. await page.evaluate(() => window.yaoyaoDesktop.updateAction('install')).catch(error => { if (!page.isClosed()) throw error })
  105. await closed; application = undefined
  106. await expect.poll(async () => readFile(join(installed, 'resources/runtime/release.json'), 'utf8').then(text => JSON.parse(text).webVersion).catch(() => ''),
  107. { timeout: 120000 }).toBe(nextVersion)
  108. await expect.poll(() => restored, { timeout: 60000 }).toBeGreaterThan(0)
  109. console.log('新版本已自动启动,登录 Cookie 保留')
  110. await stopInstalled()
  111. application = await launch(); page = await application.firstWindow(); await page.waitForURL(origin + '/**')
  112. assert.equal(await application.evaluate(({ app }) => app.getVersion()), nextVersion)
  113. assert.equal(await application.evaluate(async ({ session }, origin) =>
  114. (await session.defaultSession.cookies.get({ url: origin, name: 'session' }))[0]?.value, origin), 'upgrade-fixture')
  115. assert.deepEqual(JSON.parse(await readFile(join(home, 'desktop-preferences.json'), 'utf8')), preferences)
  116. assert.equal(await application.evaluate(async ({ safeStorage }, home) => {
  117. const fs = process.getBuiltinModule('node:fs'); return (await safeStorage.decryptStringAsync(fs.readFileSync(home + '/upgrade-token.enc'))).result
  118. }, home), 'upgrade-device-identity')
  119. await page.screenshot({ path: join(evidence, 'upgrade-restored.png') })
  120. await application.close(); application = undefined
  121. const uninstaller = (await readdir(installed)).find(name => /^Uninstall .*\.exe$/.test(name))
  122. assert.ok(uninstaller)
  123. await execute(join(installed, uninstaller), ['/S'], { timeout: 120000 })
  124. await expect.poll(async () => (await readdir(installed).catch(() => [])).includes('Yaoyao.exe'), { timeout: 30000 }).toBe(false)
  125. assert.deepEqual(JSON.parse(await readFile(join(home, 'desktop-preferences.json'), 'utf8')), preferences)
  126. await writeFile(join(evidence, 'upgrade.json'), JSON.stringify({ platform: 'win32', from: version, to: nextVersion,
  127. installed: true, corruptDownloadRejected: true, nsisReplacement: true, automaticRestart: true,
  128. cookieRestored: true, settingsPreserved: true, dpapiPreserved: true, uninstalled: true, dataRetained: true }, null, 2))
  129. } catch (error) {
  130. for (const [index, page] of (application?.windows() || []).entries()) await page.screenshot({ path: join(evidence, `upgrade-failed-${index}.png`) }).catch(() => {})
  131. throw error
  132. } finally {
  133. await application?.close().catch(() => {}); await stopInstalled()
  134. if (logs) await copyFile(join(logs, 'service/server.log'), join(evidence, 'upgrade-main.log')).catch(() => {})
  135. server.closeAllConnections(); await new Promise(done => server.close(done))
  136. await rm(sandbox, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })
  137. await rm(home, { recursive: true, force: true })
  138. if (userData) await rm(userData, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })
  139. await rm(join(process.env.LOCALAPPDATA, cacheName), { recursive: true, force: true }).catch(() => {})
  140. }
  141. })