onboarding-flow.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { createServer } from 'node:http'
  4. import { mkdtemp, realpath, rm, readFile, mkdir } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join, resolve } from 'node:path'
  7. import { _electron as electron, expect } from '@playwright/test'
  8. const root = resolve(import.meta.dirname, '..')
  9. test('first launch, failed connection and remote login all stay in the only main window', { timeout: 60000 }, async () => {
  10. const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-onboarding-')))
  11. let loginCount = 0
  12. let registrationCount = 0
  13. let loginRole = 'user', enrollmentCount = 0
  14. let holdBootstrap = false, replyBootstrap, sessionValid = true
  15. const server = createServer((req, res) => {
  16. const json = (value, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) }
  17. if (req.url.split('?')[0] === '/api/app/bootstrap') {
  18. res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
  19. const reply = () => json({ authenticated: sessionValid && String(req.headers.cookie).includes('hermes_yaoyao_session=fixture'), setupRequired: false, registrationAvailable: true, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
  20. if (holdBootstrap) { replyBootstrap = reply; return }
  21. return reply()
  22. }
  23. if (req.url === '/api/app/register') {
  24. let raw = ''; req.on('data', chunk => { raw += chunk }); req.on('end', () => {
  25. registrationCount++
  26. assert.deepEqual(JSON.parse(raw), { username: 'new-child', password: 'child-password' })
  27. assert.equal(req.headers['x-csrf-token'], 'fixture')
  28. assert.ok(String(req.headers.cookie).includes('csrf=fixture'))
  29. assert.ok(!String(req.headers.cookie).includes('session='))
  30. json({ registrationStatus: 'pending', message: '注册成功,请等待管理员开通' }, 201)
  31. }); return
  32. }
  33. if (req.url === '/api/app/login') {
  34. let raw = ''; req.on('data', chunk => { raw += chunk }); req.on('end', () => {
  35. loginCount++
  36. const body = JSON.parse(raw)
  37. assert.ok(!String(req.headers.cookie).includes('session='))
  38. if (body.username !== 'user' || body.password !== 'correct-password') return json({ error: 'wrong' }, 401)
  39. if (req.headers['x-csrf-token'] !== 'fixture' || !String(req.headers.cookie).includes('csrf=fixture')) return json({ error: 'csrf' }, 403)
  40. res.setHeader('set-cookie', 'hermes_yaoyao_session=fixture; Path=/; HttpOnly; Max-Age=3600; SameSite=Strict')
  41. json({ user: { id: 'fixture-user', username: 'user', role: loginRole }, csrfToken: 'fixture' })
  42. }); return
  43. }
  44. if (req.url === '/api/app/admin/desktop-hosts') {
  45. enrollmentCount++
  46. assert.equal(req.headers.origin, url)
  47. assert.equal(req.headers['x-csrf-token'], 'fixture')
  48. assert.ok(String(req.headers.cookie).includes('hermes_yaoyao_session=fixture'))
  49. // Exercise authorization without provisioning a real computer in the fixture.
  50. return json({ error: 'enrollment unavailable' }, 503)
  51. }
  52. res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>已登录远程夭夭</title><h1>远程工作区</h1>')
  53. })
  54. await new Promise(done => server.listen(0, '127.0.0.1', done))
  55. const url = `http://127.0.0.1:${server.address().port}`
  56. let app
  57. try {
  58. app = await electron.launch({ args: [root], cwd: root, env: { ...process.env,
  59. HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_MODE: 'ask', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' } })
  60. const page = await app.firstWindow()
  61. // The connectivity check can succeed through Chromium while Node sockets
  62. // are unavailable (for example, after a macOS local-network permission change).
  63. await app.evaluate(({ session }, origin) => {
  64. globalThis.fetch = async () => { throw new TypeError('fetch failed', { cause: { code: 'EHOSTUNREACH' } }) }
  65. return session.defaultSession.cookies.set({ url: origin, name: 'hermes_yaoyao_session', value: 'previous-account', httpOnly: true })
  66. }, url)
  67. const errors = []; page.on('pageerror', error => errors.push(error.message))
  68. await expect(page.getByRole('heading', { name: '开始使用夭夭' })).toBeVisible()
  69. assert.equal(app.windows().length, 1)
  70. assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).mode, null)
  71. await page.getByRole('radio', { name: /连接远程服务器/ }).check()
  72. await page.locator('#remember').check()
  73. await page.locator('#continue').click()
  74. await page.locator('#server').fill('http://127.0.0.1:1')
  75. await page.locator('#detect').click()
  76. await expect(page.locator('#status[role=alert]')).toBeVisible()
  77. await expect(page.locator('#login-form')).toBeHidden()
  78. await page.locator('#server').fill(url)
  79. await page.locator('#detect').click()
  80. await expect(page.locator('#status')).toHaveText('连接成功,服务器已就绪')
  81. await expect(page.locator('#login-form')).toBeVisible()
  82. assert.match(page.url(), /boot.html$/)
  83. await page.locator('#register-toggle').click()
  84. await page.locator('#username').fill('new-child')
  85. await page.locator('#password').fill('child-password')
  86. await page.locator('#confirmation').fill('child-password')
  87. await page.locator('#submit').click()
  88. await expect(page.locator('#registration-notice')).toContainText('注册成功,请等待管理员开通')
  89. await expect(page.locator('#confirmation-field')).toBeHidden()
  90. assert.equal(registrationCount, 1)
  91. assert.equal(loginCount, 0, 'registration never signs in or authorizes this computer')
  92. assert.equal(await app.evaluate(async ({ session }, origin) =>
  93. (await session.defaultSession.cookies.get({ url: origin, name: 'hermes_yaoyao_session' }))[0]?.value, url),
  94. 'previous-account', 'registration does not replace the active browser session')
  95. assert.match(page.url(), /boot.html$/)
  96. await page.locator('#username').fill('user')
  97. await page.locator('#password').fill('wrong-password')
  98. await page.locator('#submit').click()
  99. await expect(page.locator('#login-error')).toContainText('用户名或密码不正确')
  100. assert.equal(await page.locator('#password').inputValue(), '')
  101. // Editing the address invalidates the checked target and hides credentials.
  102. await page.locator('#change-server').click()
  103. await page.locator('#server').fill('http://another-server.test')
  104. await expect(page.locator('#login-form')).toBeHidden()
  105. await page.locator('#server').fill(url); await page.locator('#detect').click()
  106. assert.equal(await page.locator('#username').inputValue(), '')
  107. await page.locator('#username').fill('user')
  108. await page.locator('#password').fill('correct-password')
  109. await page.locator('#toggle-password').click()
  110. assert.equal(await page.locator('#password').getAttribute('type'), 'text')
  111. await page.locator('#toggle-password').click()
  112. await mkdir(join(root, 'docs/design/desktop-onboarding'), { recursive: true })
  113. await page.emulateMedia({ colorScheme: 'light' })
  114. assert.equal(await page.evaluate(() => document.querySelector('footer').getBoundingClientRect().bottom <= innerHeight), true, 'default window shows its footer without scrolling')
  115. await page.screenshot({ path: join(root, 'docs/design/desktop-onboarding/remote-login.png') })
  116. await page.emulateMedia({ colorScheme: 'dark' })
  117. await page.screenshot({ path: join(root, 'docs/design/desktop-onboarding/remote-dark.png') })
  118. await page.setViewportSize({ width: 720, height: 560 })
  119. assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
  120. await page.screenshot({ path: join(root, 'docs/design/desktop-onboarding/remote-narrow.png'), fullPage: true })
  121. await page.setViewportSize({ width: 1280, height: 832 })
  122. await page.emulateMedia({ colorScheme: 'light' })
  123. await page.locator('#submit').click()
  124. await page.waitForURL(url + '/**')
  125. await expect(page.getByRole('heading', { name: '远程工作区' })).toBeVisible()
  126. assert.equal(app.windows().length, 1); assert.equal(loginCount, 2)
  127. const preferences = JSON.parse(await readFile(join(home, 'desktop-preferences.json'), 'utf8'))
  128. assert.equal(preferences.startupChoice, 'remote'); assert.equal(preferences.remoteServer, url)
  129. assert.deepEqual(errors, [])
  130. // The HTTP renderer cannot invoke privileged native setup/authentication.
  131. assert.deepEqual(await page.evaluate(() => Promise.all([
  132. window.yaoyaoDesktop.status(), window.yaoyaoDesktop.selectServer('local'),
  133. window.yaoyaoDesktop.prepareServer({}), window.yaoyaoDesktop.login({ username: 'x', password: 'y' }),
  134. ].map(promise => promise.then(() => false, () => true)))), [true, true, true, true])
  135. const firstProcess = app.process()
  136. const firstExit = firstProcess.exitCode === null ? new Promise(done => firstProcess.once('exit', done)) : Promise.resolve()
  137. await app.close(); await firstExit; app = undefined
  138. holdBootstrap = true
  139. app = await electron.launch({ args: [root], cwd: root, env: { ...process.env,
  140. HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_MODE: 'ask', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' } })
  141. const reopened = await app.firstWindow()
  142. await expect.poll(() => Boolean(replyBootstrap)).toBe(true)
  143. await expect(reopened.locator('#session-loading')).toBeVisible()
  144. await expect(reopened.locator('#onboarding')).toBeHidden()
  145. await mkdir(join(root, 'test-results/desktop'), { recursive: true })
  146. await reopened.screenshot({ path: join(root, 'test-results/desktop/session-restoring.png') })
  147. let guideFlashed = false
  148. await reopened.exposeFunction('recordGuideVisibility', visible => { guideFlashed ||= visible })
  149. await reopened.evaluate(() => {
  150. const guide = document.getElementById('onboarding')
  151. new MutationObserver(() => { void window.recordGuideVisibility(!guide.hidden) }).observe(guide, { attributes: true, attributeFilter: ['hidden'] })
  152. })
  153. holdBootstrap = false; replyBootstrap()
  154. try { await reopened.waitForURL(url + '/**', { timeout: 8000 }) }
  155. catch (error) {
  156. console.log('Reopened fixture URL:', reopened.url())
  157. console.log('Reopened fixture state:', await reopened.evaluate(() => window.yaoyaoDesktop.status().catch(error => ({ error: error.message }))))
  158. console.log('Fixture cookie metadata:', await app.evaluate(async ({ session }, url) => (await session.defaultSession.cookies.get({ url })).map(({ name, expirationDate, sameSite, session }) => ({ name, expirationDate, sameSite, session })), url))
  159. throw error
  160. }
  161. assert.equal(loginCount, 2, 'a remembered authorization is restored without another password login')
  162. assert.equal(guideFlashed, false, 'restoring a valid session never displays the guide')
  163. // An expired session still returns to the same page with usable login fields.
  164. sessionValid = false
  165. await reopened.evaluate(() => { void window.yaoyaoDesktop.openLogin() })
  166. await expect(reopened.locator('#login-form')).toBeVisible()
  167. await expect(reopened.locator('#session-loading')).toBeHidden()
  168. assert.equal(app.windows().length, 1)
  169. loginRole = 'admin'
  170. await app.evaluate(() => {
  171. globalThis.fetch = async () => { throw new TypeError('fetch failed', { cause: { code: 'EHOSTUNREACH' } }) }
  172. })
  173. await reopened.locator('#username').fill('user')
  174. await reopened.locator('#password').fill('correct-password')
  175. await reopened.locator('#submit').click()
  176. await expect(reopened.locator('#login-error')).toContainText('账号已登录,但电脑授权未恢复')
  177. assert.equal(enrollmentCount, 1, 'computer authorization also uses the reachable Chromium transport')
  178. assert.equal((await reopened.evaluate(() => window.yaoyaoDesktop.status())).authenticated, true)
  179. await reopened.locator('#submit').click()
  180. await reopened.waitForURL(url + '/**')
  181. } finally {
  182. await app?.close().catch(() => {})
  183. await new Promise(done => { server.close(done); server.closeAllConnections() })
  184. await rm(home, { recursive: true, force: true })
  185. }
  186. })