nodePairingPanel.test.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import { flushPromises, mount } from '@vue/test-utils'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import QRCode from 'qrcode'
  4. import NodePairingPanel from '@/components/app/NodePairingPanel.vue'
  5. import { accountPairingStatus, createAccountPairing } from '@/api/accountPairing'
  6. import {
  7. createPairing,
  8. pairChildNode,
  9. pairedDevices,
  10. pairingStatus,
  11. revokePairedDevice,
  12. type NodeScope,
  13. } from '@/api/pairing'
  14. vi.mock('qrcode', () => ({
  15. default: {
  16. toDataURL: vi.fn(async () => 'data:image/png;base64,cXItY29kZQ=='),
  17. },
  18. }))
  19. vi.mock('@/api/accountPairing', () => ({
  20. accountPairingStatus: vi.fn(),
  21. createAccountPairing: vi.fn(),
  22. }))
  23. vi.mock('@/api/pairing', () => ({
  24. createPairing: vi.fn(),
  25. pairChildNode: vi.fn(),
  26. pairedDevices: vi.fn(),
  27. pairingStatus: vi.fn(),
  28. revokePairedDevice: vi.fn(),
  29. }))
  30. const allScopes: NodeScope[] = [
  31. 'agents.read',
  32. 'history.read',
  33. 'sessions.execute',
  34. 'groups.read',
  35. 'groups.execute',
  36. ]
  37. beforeEach(() => {
  38. vi.useFakeTimers()
  39. vi.setSystemTime(new Date('2026-08-29T04:00:00.000Z'))
  40. vi.mocked(createAccountPairing).mockResolvedValue({
  41. protocolVersion: 1,
  42. serviceType: 'hermes-yaoyao',
  43. pairingId: 'account-pairing-1',
  44. expiresAt: Date.now() + 60_000,
  45. qrPayload: 'yaoyao://login?v=1&id=account-pairing-1',
  46. })
  47. vi.mocked(accountPairingStatus).mockResolvedValue({ state: 'pending' })
  48. vi.mocked(pairedDevices).mockResolvedValue({
  49. nodeId: 'node-12345678',
  50. fingerprint: 'fingerprint-1',
  51. devices: [],
  52. })
  53. vi.mocked(createPairing).mockResolvedValue({
  54. protocolVersion: 1,
  55. pairingId: 'node-pairing-1',
  56. nodeId: 'node-12345678',
  57. fingerprint: 'fingerprint-1',
  58. scopes: [...allScopes],
  59. expiresAt: Date.now() + 60_000,
  60. qrPayload: 'yaoyao://pair?v=1&id=node-pairing-1',
  61. })
  62. vi.mocked(pairingStatus).mockResolvedValue({ state: 'pending' })
  63. vi.mocked(pairChildNode).mockResolvedValue(undefined)
  64. vi.mocked(revokePairedDevice).mockResolvedValue(undefined)
  65. })
  66. afterEach(() => {
  67. document.body.innerHTML = ''
  68. vi.clearAllTimers()
  69. vi.useRealTimers()
  70. vi.clearAllMocks()
  71. vi.restoreAllMocks()
  72. vi.unstubAllGlobals()
  73. Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: undefined })
  74. })
  75. describe('NodePairingPanel', () => {
  76. it('account 模式只创建账号登录二维码,并在停用时清理轮询', async () => {
  77. const wrapper = mount(NodePairingPanel, {
  78. attachTo: document.body,
  79. props: { active: true, mode: 'account', isAdmin: true, userName: 'admin' },
  80. global: { stubs: { AppIcon: true } },
  81. })
  82. await flushPromises()
  83. expect(createAccountPairing).toHaveBeenCalledOnce()
  84. expect(QRCode.toDataURL).toHaveBeenCalledWith(
  85. 'yaoyao://login?v=1&id=account-pairing-1',
  86. expect.objectContaining({ width: 296, margin: 2, errorCorrectionLevel: 'M' }),
  87. )
  88. expect(pairedDevices).not.toHaveBeenCalled()
  89. expect(createPairing).not.toHaveBeenCalled()
  90. expect(wrapper.text()).toContain('在 iOS 登录页选择扫码')
  91. expect(vi.getTimerCount()).toBe(1)
  92. await wrapper.setProps({ active: false })
  93. await flushPromises()
  94. expect(vi.getTimerCount()).toBe(0)
  95. wrapper.unmount()
  96. })
  97. it('nodes 模式先只加载设备,生成二维码时传递完整授权范围,并在卸载时清理轮询', async () => {
  98. const wrapper = mount(NodePairingPanel, {
  99. attachTo: document.body,
  100. props: { active: true, mode: 'nodes', isAdmin: true },
  101. global: { stubs: { AppIcon: true } },
  102. })
  103. await flushPromises()
  104. expect(pairedDevices).toHaveBeenCalledOnce()
  105. expect(createAccountPairing).not.toHaveBeenCalled()
  106. expect(createPairing).not.toHaveBeenCalled()
  107. expect(QRCode.toDataURL).not.toHaveBeenCalled()
  108. expect(wrapper.text()).toContain('还没有已授权设备')
  109. expect(vi.getTimerCount()).toBe(0)
  110. await wrapper.get('button.create-pairing').trigger('click')
  111. await flushPromises()
  112. expect(createPairing).toHaveBeenCalledWith(allScopes)
  113. expect(QRCode.toDataURL).toHaveBeenCalledWith(
  114. 'yaoyao://pair?v=1&id=node-pairing-1',
  115. expect.objectContaining({ width: 296, margin: 2, errorCorrectionLevel: 'M' }),
  116. )
  117. expect(vi.getTimerCount()).toBe(1)
  118. wrapper.unmount()
  119. expect(vi.getTimerCount()).toBe(0)
  120. })
  121. it('stops a camera stream whose startup becomes stale before video playback completes', async () => {
  122. let resolvePlay: (() => void) | undefined
  123. const play = new Promise<void>(resolve => { resolvePlay = resolve })
  124. const track = { stop: vi.fn() }
  125. const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream)
  126. Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia } })
  127. vi.stubGlobal('BarcodeDetector', class {
  128. detect = vi.fn(async () => [])
  129. })
  130. const playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play').mockReturnValue(play)
  131. const wrapper = mount(NodePairingPanel, {
  132. attachTo: document.body,
  133. props: { active: true, mode: 'nodes', isAdmin: true },
  134. global: { stubs: { AppIcon: true } },
  135. })
  136. await flushPromises()
  137. const scan = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('使用摄像头扫码'))!
  138. await scan.trigger('click')
  139. await flushPromises()
  140. await vi.advanceTimersByTimeAsync(0)
  141. await flushPromises()
  142. expect(getUserMedia).toHaveBeenCalledOnce()
  143. expect(playSpy).toHaveBeenCalledOnce()
  144. const stop = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('停止扫码'))!
  145. await stop.trigger('click')
  146. expect(track.stop).toHaveBeenCalledOnce()
  147. resolvePlay?.()
  148. await flushPromises()
  149. expect(vi.getTimerCount()).toBe(0)
  150. expect(wrapper.text()).not.toContain('停止扫码')
  151. wrapper.unmount()
  152. })
  153. })