windows-smoke.test.mjs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { createServer } from 'node:http'
  4. import { mkdtemp, readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join, resolve } from 'node:path'
  7. import { randomUUID } from 'node:crypto'
  8. import { _electron as electron, expect } from '@playwright/test'
  9. test('packaged Windows client logs in, encrypts pairing, restores its session and never starts a server', { skip: process.platform !== 'win32', timeout: 90000 }, async () => {
  10. const home = await mkdtemp(join(tmpdir(), '夭夭-windows-')), hostId = randomUUID(), token = 'windows-fixture-token-not-for-production'
  11. let exchanges = 0, app
  12. const server = createServer((req, res) => {
  13. const json = (body, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)) }
  14. if (req.url.split('?')[0] === '/api/app/bootstrap') {
  15. res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
  16. return json({ authenticated: String(req.headers.cookie).includes('session=fixture'), setupRequired: false, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
  17. }
  18. if (req.url === '/api/app/login') {
  19. req.resume(); res.setHeader('set-cookie', 'session=fixture; Path=/; HttpOnly; Max-Age=3600')
  20. return json({ user: { id: 'fixture', username: 'admin', role: 'admin' }, csrfToken: 'fixture' })
  21. }
  22. if (req.url === '/api/app/admin/desktop-hosts') { req.resume(); return json({ host: { id: hostId }, token }) }
  23. if (req.url === `/api/desktop-host/v1/${hostId}/exchange`) {
  24. let raw = ''; req.on('data', bytes => { raw += bytes }); req.on('end', () => {
  25. const body = JSON.parse(raw)
  26. assert.equal(body.host.platform, 'win32'); assert.equal(req.headers.authorization, `Bearer ${token}`)
  27. exchanges++; json({ commands: [], capabilities: { environmentMetadata: 1, desktopPlatforms: ['darwin', 'win32'] } })
  28. }); return
  29. }
  30. res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>Windows 登录验证</title><h1>远程工作区</h1><textarea id="native-input" style="width:400px;height:140px"></textarea>')
  31. })
  32. await new Promise(done => server.listen(0, '127.0.0.1', done))
  33. const url = `http://127.0.0.1:${server.address().port}`
  34. const launch = () => electron.launch({ executablePath: process.env.DESKTOP_TEST_EXECUTABLE || resolve('desktop-release/windows-x64/win-unpacked/Yaoyao.exe'),
  35. args: [], env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home }, timeout: 30000 })
  36. const evidence = resolve('test-results/windows-client')
  37. try {
  38. app = await launch(); let page = await app.firstWindow()
  39. await expect(page.locator('#connection-form')).toBeVisible()
  40. await expect(page.locator('#choice-section')).toBeHidden()
  41. assert.deepEqual((await page.evaluate(() => window.yaoyaoDesktop.status())).supportedModes, ['client'])
  42. assert.equal(await page.evaluate(async () => { try { await window.yaoyaoDesktop.selectServer('local'); return false } catch { return true } }), true)
  43. await page.locator('#server').fill(url); await page.locator('#detect').click()
  44. await expect(page.locator('#login-form')).toBeVisible()
  45. await page.locator('#username').fill('admin'); await page.locator('#password').fill('fixture-password'); await page.locator('#remember').check(); await page.locator('#submit').click()
  46. await page.waitForURL(url + '/**')
  47. const state = await page.evaluate(() => window.yaoyaoDesktop.modeState())
  48. assert.equal(state.mode, 'client'); assert.deepEqual(state.supportedModes, ['client'])
  49. assert.equal((await page.evaluate(() => window.yaoyaoDesktop.switchMode('server'))).ok, false)
  50. await expect.poll(() => exchanges).toBeGreaterThan(0)
  51. const bytes = await readFile(join(home, 'desktop-host.enc'))
  52. assert.equal(bytes.includes(token), false); assert.equal(bytes.includes('fixture-password'), false)
  53. assert.equal(bytes.subarray(0, Buffer.byteLength('YAOYAO-WINDOWS-DPAPI-1\n')).toString(), 'YAOYAO-WINDOWS-DPAPI-1\n')
  54. assert.equal((await readdir(home)).includes('service-instance.json'), false)
  55. const menu = await app.evaluate(({ Menu }) => ['desktop-host-use-local', 'desktop-stop-and-quit', 'runner-import'].map(id => Boolean(Menu.getApplicationMenu().getMenuItemById(id))))
  56. assert.deepEqual(menu, [false, false, false])
  57. const native = await app.evaluate(async ({ app, BrowserWindow, screen }, { home, url }) => {
  58. const { createRequire } = process.getBuiltinModule('node:module')
  59. const { join } = process.getBuiltinModule('node:path')
  60. const { DesktopHostCore } = createRequire(join(app.getAppPath(), 'main.mjs'))('./environment-host.mjs')
  61. const core = new DesktopHostCore({ root: join(process.resourcesPath, 'runtime'), dataRoot: join(home, 'native-acceptance') })
  62. const owner = 'a'.repeat(64), resource = 'b'.repeat(64)
  63. const command = { owner, resource, mode: 'local', profile: 'temporary', deadline: Date.now() + 30000 }
  64. core.approved.add(owner); core.approvedFull.add(owner)
  65. try {
  66. const shell = await core.execute({ ...command, operation: 'shell', action: { command: "[Console]::WriteLine('原生命令')" } })
  67. if (shell.exitCode !== 0 || shell.stdout.trim() !== '原生命令') throw new Error('打包后的 PowerShell 助手执行失败:' + shell.stderr)
  68. await core.execute({ ...command, mode: 'browser', operation: 'open' })
  69. await core.execute({ ...command, mode: 'browser', operation: 'browser', action: { kind: 'navigate', url } })
  70. const browserFrame = await core.execute({ ...command, mode: 'browser', operation: 'view' })
  71. if (!browserFrame.data || (await core.browser.sessions.get(resource).cookies.get({ url })).some(cookie => cookie.name === 'session')) throw new Error('独立浏览器画面或会话隔离失败')
  72. let unavailable
  73. try { await core.native.call({ operation: 'probe' }) } catch (error) { unavailable = error.message }
  74. if (unavailable) return { shell: true, isolatedBrowser: true, screenInput: 'unavailable in CI session', reason: unavailable }
  75. const frame = await core.execute({ ...command, operation: 'view' }), display = screen.getPrimaryDisplay()
  76. const target = BrowserWindow.getAllWindows().find(w => w.isVisible() && w.webContents.getURL().startsWith(url)), bounds = target.getContentBounds()
  77. target.show(); target.focus()
  78. const rect = await target.webContents.executeJavaScript('(()=>{const r=document.querySelector("textarea").getBoundingClientRect();return {x:r.x+30,y:r.y+30}})()')
  79. const physical = screen.dipToScreenPoint({ x: Math.round(bounds.x + rect.x), y: Math.round(bounds.y + rect.y) })
  80. const primary = screen.dipToScreenRect(null, display.bounds)
  81. await core.execute({ ...command, operation: 'input', frame, action: { kind: 'click', x: Math.floor(physical.x * frame.width / primary.width), y: Math.floor(physical.y * frame.height / primary.height) } })
  82. await core.execute({ ...command, operation: 'input', frame, action: { kind: 'text', text: '你好,Windows🙂' } })
  83. for (let i = 0; i < 30; i++) {
  84. if (await target.webContents.executeJavaScript('document.querySelector("textarea").value') === '你好,Windows🙂') break
  85. await new Promise(done => setTimeout(done, 100))
  86. }
  87. if (await target.webContents.executeJavaScript('document.querySelector("textarea").value') !== '你好,Windows🙂') throw new Error('原生鼠标点击或 Unicode 输入失败')
  88. await core.execute({ ...command, operation: 'input', frame, action: { kind: 'key', key: 'a', modifiers: ['ctrl'] } })
  89. await core.execute({ ...command, operation: 'input', frame, action: { kind: 'text', text: '组合键已验证' } })
  90. if (await target.webContents.executeJavaScript('document.querySelector("textarea").value') !== '组合键已验证') throw new Error('原生 Ctrl+A 输入失败')
  91. screen.emit('display-metrics-changed', {}, display, ['scaleFactor'])
  92. let staleRejected = false
  93. try { await core.execute({ ...command, operation: 'input', frame, action: { kind: 'key', key: 'Tab' } }) } catch (error) { staleRejected = /刷新画面/.test(error.message) }
  94. if (!staleRejected) throw new Error('显示器变化后必须重新截图')
  95. await core.revoke()
  96. let revoked = false
  97. try { await core.execute({ ...command, operation: 'shell', action: { command: 'exit 0' } }) } catch { revoked = true }
  98. if (!revoked) throw new Error('撤销后仍允许命令执行')
  99. return { shell: true, isolatedBrowser: true, screenshot: true, click: true, unicodeInput: true, ctrlA: true, displayInvalidation: true, revoked: true, scaleFactor: display.scaleFactor }
  100. } finally { await core.close() }
  101. }, { home, url })
  102. await mkdir(evidence, { recursive: true }); await page.screenshot({ path: join(evidence, 'remote-login.png') })
  103. await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].close())
  104. assert.equal(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), false)
  105. await app.close(); app = undefined
  106. const before = exchanges; app = await launch(); page = await app.firstWindow()
  107. await page.waitForURL(url + '/**'); await expect.poll(() => exchanges).toBeGreaterThan(before)
  108. await writeFile(join(evidence, 'smoke.json'), JSON.stringify({ platform: 'win32', login: true, encryptedPairing: true, sessionRestored: true,
  109. clientOnly: true, native, windows10and11Matrix: 'requires Windows 10/11 acceptance; CI runs Windows Server 2022' }, null, 2))
  110. } finally { await app?.close().catch(() => {}); await new Promise(done => server.close(done)); await rm(home, { recursive: true, force: true }) }
  111. })