customer-sse.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import assert from 'node:assert/strict';
  2. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  3. import { execFile } from 'node:child_process';
  4. import { promisify } from 'node:util';
  5. import { fileURLToPath } from 'node:url';
  6. import { chromium } from 'playwright';
  7. const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
  8. const started = Date.now();
  9. const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762';
  10. const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
  11. const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
  12. const page = await context.newPage();
  13. page.setDefaultTimeout(20000);
  14. const checks = [], errors = [], streams = [], ended = new Set();
  15. let historyReads = 0, writes = 0, browserRequests = 0, serviceRequests = 0, customerToken, staffToken, conversationId, report;
  16. const held = [];
  17. page.on('pageerror', error => errors.push(error.message));
  18. page.on('request', request => {
  19. if (/\/(openapi|admin)\//.test(request.url())) browserRequests++;
  20. if (request.url().endsWith('/events')) streams.push({ request, after: request.headers()['last-event-id'] });
  21. if (request.method() === 'GET' && request.url().includes('/messages?')) historyReads++;
  22. if (request.method() === 'POST' && request.url().endsWith('/messages')) writes++;
  23. });
  24. page.on('requestfailed', request => ended.add(request));
  25. page.on('requestfinished', request => ended.add(request));
  26. const content = page.locator('.mobile .content');
  27. const timeout = setTimeout(() => { void browser.close(); }, 240000);
  28. async function request(token, method, path, data, expected = 200) {
  29. serviceRequests++;
  30. const response = await context.request.fetch(`${base}${path}`, {
  31. method, headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': crypto.randomUUID() }, data,
  32. });
  33. assert.equal(response.status(), expected, await response.text());
  34. return expected === 204 ? null : response.json();
  35. }
  36. async function navigate(target) {
  37. await page.evaluate(value => { location.hash = `/customer/${value}`; }, target);
  38. await page.locator(`.mobile[data-page="${target}"]`).waitFor();
  39. await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
  40. }
  41. async function until(predicate, duration = 20000) {
  42. const deadline = Date.now() + duration;
  43. while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
  44. assert(predicate(), '限定时间内没有完成预期的事件连接操作');
  45. }
  46. async function sendStaff(text) {
  47. const conversation = await request(staffToken, 'GET', `/openapi/v1/conversations/${conversationId}`);
  48. return request(staffToken, 'POST', `/openapi/v1/conversations/${conversationId}/messages`, { text, expected_control_version: conversation.control_version }, 201);
  49. }
  50. try {
  51. serviceRequests++;
  52. const staff = await context.request.post(`${base}/openapi/v1/auth/sessions`, { data: { tenant_id: accounts.tenant_id, username: 'staff01', password: accounts.password } });
  53. assert.equal(staff.status(), 200);
  54. staffToken = (await staff.json()).access_token;
  55. await page.goto(`${base}/#/customer/login`);
  56. await content.getByLabel('账户', { exact: true }).fill('customer01');
  57. await content.getByLabel('密码', { exact: true }).fill(accounts.password);
  58. await content.getByRole('checkbox').check();
  59. const authenticated = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith('/auth/sessions'));
  60. await content.getByRole('button', { name: '登录并继续', exact: true }).click();
  61. customerToken = (await (await authenticated).json()).access_token;
  62. await until(() => streams.length > 0);
  63. conversationId = new URL(streams[0].request.url()).pathname.split('/').at(-2);
  64. await content.getByRole('button', { name: '发送', exact: true }).waitFor();
  65. await page.waitForFunction(() => ![...document.querySelectorAll('.mobile button')].some(button => button.textContent === '重新连接'));
  66. const initial = await request(staffToken, 'GET', `/openapi/v1/conversations/${conversationId}`);
  67. assert.equal(initial.active_run_id, null, '本专项要求会话当前没有生成运行');
  68. if (initial.control_owner === 'AGENT') await request(staffToken, 'POST', `/openapi/v1/conversations/${conversationId}/takeover`, { expected_control_version: initial.control_version });
  69. else assert.equal(initial.assigned_staff_id, accounts.actors.staff01);
  70. await content.getByText('人工客服正在为你服务。', { exact: true }).waitFor();
  71. const initialReads = historyReads;
  72. const text = `SSE 人工回复 ${Date.now()}`;
  73. const first = await sendStaff(text);
  74. await content.locator('.message').filter({ hasText: text }).waitFor();
  75. assert.equal(await content.locator('.message').filter({ hasText: text }).count(), 1);
  76. assert.equal(historyReads, initialReads, '人工回复应通过 SSE 到达');
  77. checks.push('真实客服接管和回复通过 SSE 到达,消息只显示一次');
  78. const connectionCount = streams.length;
  79. await until(() => streams.length > connectionCount, 55000);
  80. assert.equal(streams.at(-1).after, String(first.sequence));
  81. assert.equal(historyReads, initialReads, '正常连接续传不轮询消息历史');
  82. checks.push('50 秒连接结束后自动携带 Last-Event-ID 续传');
  83. const draft = `网络恢复后保留的客户输入 ${Date.now()}`;
  84. await content.getByLabel('补充问题', { exact: true }).fill(draft);
  85. await context.setOffline(true);
  86. await content.getByRole('button', { name: '重新连接', exact: true }).waitFor();
  87. assert.equal(await content.getByRole('button', { name: '发送', exact: true }).isDisabled(), true);
  88. await context.setOffline(false);
  89. const missing = `断线期间的真实客服消息 ${Date.now()}`;
  90. await sendStaff(missing);
  91. await content.getByRole('button', { name: '重新连接', exact: true }).click();
  92. await content.locator('.message').filter({ hasText: missing }).waitFor();
  93. assert.equal(await content.getByLabel('补充问题', { exact: true }).inputValue(), draft);
  94. assert.equal(writes, 0, '重新连接不能自动提交客户输入');
  95. await content.getByRole('button', { name: '发送', exact: true }).click();
  96. await content.locator('.message.customer').filter({ hasText: draft }).waitFor();
  97. assert.equal(writes, 1);
  98. checks.push('真实断网禁用发送,恢复后补充读取消息并保留输入,客户手动发送一次');
  99. const currentStream = streams.at(-1).request;
  100. await navigate('profile');
  101. await until(() => ended.has(currentStream));
  102. const countAway = streams.length;
  103. await new Promise(resolve => setTimeout(resolve, 1000));
  104. assert.equal(streams.length, countAway);
  105. checks.push('离开咨询页面终止事件连接,其他页面不创建连接');
  106. const root = fileURLToPath(new URL('../../', import.meta.url));
  107. await promisify(execFile)(fileURLToPath(new URL('../../.venv/Scripts/python.exe', import.meta.url)), ['-m', 'zhifu.operations.conversation_retention', '--tenant', accounts.tenant_id, '--conversation', conversationId, '--before', new Date().toISOString()], { cwd: root, timeout: 20000, windowsHide: true });
  108. // 仅提交一次过期游标,响应及随后的状态恢复均来自真实业务服务。
  109. await page.route('**/events', route => route.continue({ headers: { ...route.request().headers(), 'last-event-id': '0' } }), { times: 1 });
  110. const expired = page.waitForResponse(response => response.url().endsWith('/events') && response.status() === 410);
  111. await navigate('chat');
  112. assert.equal((await (await expired).json()).code, 'EVENT_CURSOR_EXPIRED');
  113. await page.getByText('事件续传记录已超过保留期限,已重新读取历史及当前状态。', { exact: true }).waitFor();
  114. await content.locator('.message.customer').filter({ hasText: draft }).waitFor();
  115. assert.equal(await content.locator('.message.customer').filter({ hasText: draft }).count(), 1);
  116. await page.waitForFunction(() => ![...document.querySelectorAll('.mobile button')].some(button => button.textContent === '重新连接'));
  117. assert.equal(writes, 1);
  118. checks.push('真实保留窗口与一次过期请求游标触发 410,恢复历史和当前状态且不重复提交消息');
  119. await navigate('profile');
  120. await new Promise(resolve => setTimeout(resolve, 1000));
  121. // 三条真实订阅占用同一账户额度,第四条由业务服务返回 429。
  122. const beforeLimit = await request(customerToken, 'GET', `/openapi/v1/conversations/${conversationId}`);
  123. await Promise.all(Array.from({ length: 3 }, async () => {
  124. const controller = new AbortController(); held.push(controller);
  125. serviceRequests++;
  126. const response = await fetch(`${base}/openapi/v1/conversations/${conversationId}/events`, { headers: { Authorization: `Bearer ${customerToken}`, 'Last-Event-ID': String(beforeLimit.sequence) }, signal: controller.signal });
  127. assert.equal(response.status, 200);
  128. }));
  129. const limited = page.waitForResponse(response => response.url().endsWith('/events') && response.status() === 429);
  130. await navigate('chat');
  131. const retryAfter = Number((await limited).headers()['retry-after']);
  132. assert.ok(retryAfter > 0 && retryAfter <= 60);
  133. await content.getByRole('button', { name: '重新连接', exact: true }).waitFor();
  134. assert.equal(await content.getByRole('button', { name: '发送', exact: true }).isDisabled(), true);
  135. assert.equal(await content.getByRole('button', { name: '重新连接', exact: true }).isDisabled(), true);
  136. assert.equal(await content.getByRole('button', { name: '重新加载', exact: true }).isDisabled(), true);
  137. held.forEach(controller => controller.abort());
  138. await new Promise(resolve => setTimeout(resolve, 1000));
  139. await content.getByRole('button', { name: '重新连接', exact: true }).click({ timeout: (retryAfter + 5) * 1000 });
  140. await page.waitForFunction(() => ![...document.querySelectorAll('.mobile button')].some(button => button.textContent === '重新连接'));
  141. checks.push('三条真实账户订阅触发 429,释放额度后用户可以重新连接');
  142. const current = await request(staffToken, 'GET', `/openapi/v1/conversations/${conversationId}`);
  143. await request(staffToken, 'POST', `/openapi/v1/conversations/${conversationId}/release`, { expected_control_version: current.control_version });
  144. await content.getByText('智能助手服务中', { exact: true }).waitFor();
  145. await request(customerToken, 'DELETE', '/openapi/v1/auth/sessions/current', undefined, 204);
  146. customerToken = null;
  147. await page.waitForURL('**/#/customer/login');
  148. assert.equal(await content.locator('.message').count(), 0);
  149. checks.push('释放接管实时恢复助手状态,撤销真实会话后停止订阅并进入登录页');
  150. assert.deepEqual(errors, []);
  151. report = { date: new Date().toISOString(), checks, browserErrors: errors, streamConnections: streams.length, historyReads, customerWrites: writes, generatedModelCalls: 0 };
  152. console.log(checks.join('\n'));
  153. } finally {
  154. clearTimeout(timeout);
  155. held.forEach(controller => controller.abort());
  156. await context.setOffline(false);
  157. if (conversationId && staffToken) {
  158. const current = await request(staffToken, 'GET', `/openapi/v1/conversations/${conversationId}`);
  159. if (current.control_owner === 'HUMAN' && current.assigned_staff_id === accounts.actors.staff01) await request(staffToken, 'POST', `/openapi/v1/conversations/${conversationId}/release`, { expected_control_version: current.control_version });
  160. }
  161. if (customerToken) await request(customerToken, 'DELETE', '/openapi/v1/auth/sessions/current', undefined, 204);
  162. if (staffToken) await request(staffToken, 'DELETE', '/openapi/v1/auth/sessions/current', undefined, 204);
  163. await browser.close();
  164. if (report) {
  165. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
  166. await writeFile(new URL('../../.work/frontend-verification/customer-sse.json', import.meta.url), JSON.stringify({ ...report, seconds: (Date.now() - started) / 1000, browserRequests, serviceRequests, requestCount: browserRequests + serviceRequests, durationLimitSeconds: 240 }, null, 2));
  167. }
  168. }