desktop-updates.e2e.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. /** Built workspace chrome with a controlled Desktop carrier; no Electron or installer is exercised. */
  2. import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { chromium } from 'playwright'
  6. import { describe, expect, it } from 'vitest'
  7. import { presentDesktopUpdate } from '../../desktop/src/update-presentation.ts'
  8. import { en } from '../../desktop/src/locale.ts'
  9. import { launchWebScaffold, watchConsole } from './scaffold.ts'
  10. // Mirrors the preload's presentation-only API; importing Client projects would mix compiler faces.
  11. type Presentation = ReturnType<typeof presentDesktopUpdate>
  12. interface CarrierFixture {
  13. publish(state: Presentation): void
  14. opens: number
  15. listeners: Set<(state: Presentation) => void>
  16. }
  17. type FixtureWindow = Window & typeof globalThis & { updateFixture: CarrierFixture }
  18. describe('web e2e: Desktop update workspace chrome', () => {
  19. it.each(['zh-CN', 'en-US'])('renders update states and routes explicit actions in %s', async (locale) => {
  20. const scaffold = await launchWebScaffold({})
  21. try {
  22. const browser = await chromium.launch()
  23. try {
  24. const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale })
  25. await page.addInitScript(() => {
  26. let current: Presentation = { phase: 'idle' }
  27. const listeners = new Set<(state: Presentation) => void>()
  28. const fixture: CarrierFixture = {
  29. opens: 0, listeners,
  30. publish(state) { current = state; for (const listener of listeners) listener(state) },
  31. }
  32. Object.assign(window, {
  33. updateFixture: fixture,
  34. dshDesktop: { protocolVersion: 1, updates: {
  35. status: async () => current,
  36. open: async () => { fixture.opens += 1 },
  37. subscribe(listener: (state: Presentation) => void) { listeners.add(listener); return () => listeners.delete(listener) },
  38. } },
  39. })
  40. })
  41. const tripwire = watchConsole(page)
  42. const evidenceRoot = fileURLToPath(new URL('../../desktop/.desktop-build/qualification/', import.meta.url))
  43. await mkdir(evidenceRoot, { recursive: true })
  44. const evidence = await mkdtemp(join(evidenceRoot, `workspace-updates-${locale}-`))
  45. try {
  46. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  47. await page.waitForSelector('[class*="frame"]')
  48. await expect.poll(() => page.evaluate(() => (window as FixtureWindow).updateFixture.listeners.size)).toBe(1)
  49. const availableLabel = locale === 'zh-CN' ? '新版本' : 'Update'
  50. const retryLabel = locale === 'zh-CN' ? '重试更新' : 'Retry update'
  51. const errorDetail = locale === 'zh-CN' ? '下载更新失败,请重试。' : 'Could not download the update. Please try again.'
  52. const readyLabel = locale === 'zh-CN' ? '安装并重启' : 'Install and Restart'
  53. const version = '0.1.5-nightly.20260911'
  54. // The carrier classification deliberately uses English shell copy; Web copy follows its own locale.
  55. const available = presentDesktopUpdate({ phase: 'available', version }, en)
  56. const publish = async (state: Presentation) => page.evaluate((value) => {
  57. (window as FixtureWindow).updateFixture.publish(value)
  58. }, state)
  59. const opens = async () => page.evaluate(() => (window as FixtureWindow).updateFixture.opens)
  60. await publish(available)
  61. const update = page.getByRole('button', { name: availableLabel, exact: true })
  62. await update.waitFor()
  63. expect(await opens()).toBe(0)
  64. const geometry = await update.boundingBox()
  65. expect(geometry).not.toBeNull()
  66. expect(geometry!.x).toBeLessThan(350)
  67. expect(geometry!.y).toBeGreaterThan(650)
  68. await page.screenshot({ path: join(evidence, 'available.png') })
  69. await update.click()
  70. await expect.poll(opens).toBe(1)
  71. const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }, en)
  72. await publish(progress)
  73. const downloading = page.getByRole('button', { name: '58%…', exact: true })
  74. await downloading.waitFor()
  75. expect(await downloading.getAttribute('aria-disabled')).toBe('true')
  76. expect(await downloading.locator('svg').count()).toBe(1)
  77. // Force dispatch past Playwright's aria-disabled guard to verify the product's click guard.
  78. await downloading.click({ force: true })
  79. expect(await opens()).toBe(1)
  80. await page.screenshot({ path: join(evidence, 'downloading.png') })
  81. const collapse = locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar'
  82. const expand = locale === 'zh-CN' ? '打开侧边栏' : 'Open sidebar'
  83. await page.getByRole('button', { name: collapse, exact: true }).click()
  84. const toggle = page.getByRole('button', { name: expand, exact: true })
  85. const badge = toggle.getByRole('img', { name: '58%…', exact: true })
  86. await badge.waitFor()
  87. await expect.poll(() => downloading.count()).toBe(0)
  88. expect(await page.getByRole('img', { name: '58%…', exact: true }).count()).toBe(1)
  89. const blue = await badge.evaluate(element => getComputedStyle(element).backgroundColor)
  90. await page.screenshot({ path: join(evidence, 'collapsed.png') })
  91. const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }, en)
  92. await publish(error)
  93. const errorBadge = toggle.getByRole('img', { name: retryLabel, exact: true })
  94. await errorBadge.waitFor()
  95. expect(await errorBadge.getAttribute('data-error')).toBe('true')
  96. const red = await errorBadge.evaluate(element => getComputedStyle(element).backgroundColor)
  97. expect(red).not.toBe(blue)
  98. await errorBadge.hover()
  99. const tooltip = page.getByRole('tooltip', { name: errorDetail, exact: true })
  100. await expect.poll(() => tooltip.evaluateAll(elements => elements.map(element => getComputedStyle(element).opacity))).toEqual(['1'])
  101. await page.screenshot({ path: join(evidence, 'collapsed-error.png') })
  102. await toggle.click()
  103. const retry = page.getByRole('button', { name: retryLabel, exact: true })
  104. await retry.waitFor()
  105. await expect.poll(() => errorBadge.count()).toBe(0)
  106. // The expanding ancestor can move the button after hover's element-only stability check.
  107. await retry.evaluate(async (element) => {
  108. const movements = document.getAnimations().filter(animation => animation.effect instanceof KeyframeEffect
  109. && animation.effect.target instanceof Element && animation.effect.target.contains(element)
  110. && animation.effect.getComputedTiming().endTime !== Infinity)
  111. await Promise.all(movements.map(animation => animation.finished))
  112. })
  113. await retry.hover()
  114. await expect.poll(() => tooltip.evaluateAll(elements => elements.map(element => getComputedStyle(element).opacity))).toEqual(['1'])
  115. await page.screenshot({ path: join(evidence, 'retry.png') })
  116. await retry.click()
  117. await expect.poll(opens).toBe(2)
  118. await publish(presentDesktopUpdate({ phase: 'ready', version }, en))
  119. const ready = page.getByRole('button', { name: readyLabel, exact: true })
  120. await ready.waitFor()
  121. expect(await ready.getAttribute('aria-disabled')).toBe('false')
  122. expect(await opens()).toBe(2)
  123. await page.screenshot({ path: join(evidence, 'ready.png') })
  124. await ready.click()
  125. await expect.poll(opens).toBe(3)
  126. expect(tripwire.pageErrors).toEqual([])
  127. expect(tripwire.warnings).toEqual([])
  128. expect(await page.evaluate(() => (window as FixtureWindow).updateFixture.listeners.size)).toBe(1)
  129. await writeFile(join(evidence, 'result.json'), JSON.stringify({ locale, geometry, blue, red,
  130. passed: true, explicitActions: 3, carrier: 'substituted', host: 'real Web composition',
  131. electron: false, installerExecuted: false }, null, 2) + '\n')
  132. console.log(`Desktop workspace chrome evidence: ${evidence}`)
  133. } catch (error) {
  134. await page.screenshot({ path: join(evidence, 'failure.png') }).catch(() => undefined)
  135. throw error
  136. }
  137. } finally { await browser.close() }
  138. } finally { await scaffold.close() }
  139. })
  140. })