workspaceStopUi.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. // @vitest-environment node
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import type { AddressInfo } from 'node:net'
  8. import serve from 'koa-static'
  9. import { WebSocketServer, type WebSocket } from 'ws'
  10. import { chromium, expect as expectPage } from '@playwright/test'
  11. import { loadServerConfig } from '../../src/server/config.js'
  12. import { createAuthenticatedApplication } from './authenticatedApplication.js'
  13. import type { ApplicationRuntime } from '../../src/server/app.js'
  14. import type { WorkspaceRun } from '../../src/shared/workspace.js'
  15. const cleanups: Array<() => void | Promise<void>> = []
  16. afterEach(async () => {
  17. for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
  18. })
  19. it('stops a live browser conversation immediately when the runner never confirms interruption', { timeout: 20_000 }, async () => {
  20. const home = mkdtempSync(join(tmpdir(), 'yaoyao-stop-ui-'))
  21. const gateway = new WebSocketServer({ host: '127.0.0.1', port: 0 })
  22. await new Promise<void>(resolve => gateway.once('listening', resolve))
  23. const gatewayPort = (gateway.address() as AddressInfo).port
  24. const prompts: string[] = []
  25. let interruptCount = 0
  26. gateway.on('connection', (socket: WebSocket) => {
  27. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready' } }))
  28. socket.on('message', raw => {
  29. const frame = JSON.parse(String(raw))
  30. if (frame.method === 'session.create' || frame.method === 'session.resume') {
  31. socket.send(JSON.stringify({
  32. id: frame.id,
  33. result: { session_id: randomUUID(), stored_session_id: randomUUID(), running: false, info: { profile_name: 'default' } },
  34. }))
  35. } else if (frame.method === 'prompt.submit') {
  36. prompts.push(frame.params.text)
  37. socket.send(JSON.stringify({ id: frame.id, result: { status: 'streaming' } }))
  38. } else if (frame.method === 'session.cwd.set') {
  39. socket.send(JSON.stringify({ id: frame.id, result: { cwd: frame.params.cwd } }))
  40. } else if (frame.method === 'session.interrupt') {
  41. interruptCount++
  42. // Deliberately never reply: this is the unreachable-runner failure mode.
  43. } else {
  44. socket.send(JSON.stringify({ id: frame.id, result: {} }))
  45. }
  46. })
  47. })
  48. const config = loadServerConfig({
  49. HERMES_YAOYAO_HOME: home,
  50. HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${gatewayPort}`,
  51. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  52. })
  53. const runtime: ApplicationRuntime = createAuthenticatedApplication({
  54. config,
  55. fetchImpl: async input => {
  56. const path = new URL(String(input)).pathname
  57. const body = path === '/api/config'
  58. ? { terminal: { cwd: '.' } }
  59. : path === '/api/profiles'
  60. ? { profiles: [{ name: 'default', display_name: '基础 Agent' }] }
  61. : path === '/api/status'
  62. ? { auth_required: true }
  63. : path === '/api/auth/me'
  64. ? { user_id: 'upstream' }
  65. : { ok: true }
  66. return new Response(JSON.stringify(body), {
  67. status: path.includes('/plugins/yaoyao-bot-bridge/') ? 404 : 200,
  68. headers: { 'Content-Type': 'application/json' },
  69. })
  70. },
  71. })
  72. runtime.auth.isUserActive = vi.fn(() => true)
  73. runtime.auth.pushAuthorizationVersion = vi.fn(() => 0)
  74. const dist = join(import.meta.dirname, '../../dist')
  75. runtime.app.use(serve(dist, { index: 'index.html' }))
  76. runtime.app.use(async ctx => {
  77. if (ctx.method === 'GET' && ctx.accepts('html')) {
  78. ctx.type = 'html'
  79. ctx.body = readFileSync(join(dist, 'index.html'))
  80. }
  81. })
  82. const owner = 'test-admin'
  83. const nodes = (runtime.workspaceRuntime as unknown as { nodes: { target: (owner: string, nodeId: string) => any; requireSource: () => void } }).nodes
  84. const target = nodes.target(owner, 'local')
  85. target.session.webSocketCredential = async () => ({ name: 'ticket', value: 'stop-ui-test' })
  86. nodes.requireSource = () => {}
  87. const agent = runtime.workspace.createAgent(owner, { name: '停止验证', profile: 'default' })
  88. const conversation = runtime.workspace.list<any>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)!
  89. const run = runtime.workspaceRuntime.send(owner, conversation.id, { requestId: randomUUID(), content: '等待停止' })
  90. const http = await new Promise<{ url: string; close: () => Promise<void> }>(resolve => {
  91. const server = runtime.app.listen(0, '127.0.0.1', () => {
  92. const address = server.address() as AddressInfo
  93. resolve({ url: `http://127.0.0.1:${address.port}`, close: () => new Promise(done => server.close(done)) })
  94. })
  95. })
  96. const browser = await chromium.launch()
  97. const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
  98. cleanups.push(
  99. async () => { await browser.close() },
  100. async () => { await http.close() },
  101. () => runtime.close(),
  102. () => {
  103. for (const socket of gateway.clients) socket.terminate()
  104. gateway.close()
  105. rmSync(home, { recursive: true, force: true })
  106. },
  107. )
  108. await vi.waitFor(() => {
  109. const current = runtime.workspace.require<WorkspaceRun>(owner, 'run', run.id)
  110. expect(prompts, `${current.status}: ${current.error}`).toHaveLength(1)
  111. })
  112. await page.goto(`${http.url}/conversations/${conversation.id}`)
  113. await expectPage(page.getByRole('button', { name: '停止生成' })).toBeVisible()
  114. await expectPage(page.getByLabel('机器人正在输入')).toBeVisible()
  115. const stopped = page.waitForResponse(
  116. response => response.url().endsWith(`/api/app/runs/${run.id}/stop`) && response.request().method() === 'POST',
  117. { timeout: 2_000 },
  118. )
  119. await page.getByRole('button', { name: '停止生成' }).click()
  120. await expect((await stopped).status()).toBe(200)
  121. const storedRun = runtime.workspace.require<WorkspaceRun>(owner, 'run', run.id)
  122. expect(storedRun).toMatchObject({ status: 'interrupted', stopRequested: true })
  123. expect(runtime.workspace.require<any>(owner, 'conversation', conversation.id).activeRunId).toBeUndefined()
  124. await expectPage(page.getByLabel('机器人正在输入')).toBeHidden({ timeout: 2_000 })
  125. expect(interruptCount).toBe(1)
  126. mkdirSync(join(import.meta.dirname, '../../test-results/workspaceStopUi'), { recursive: true })
  127. await page.screenshot({ path: join(import.meta.dirname, '../../test-results/workspaceStopUi/stopped.png'), fullPage: true })
  128. })