verify-interrupt-ui.mjs 3.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. #!/usr/bin/env node
  2. /**
  3. * 打断场景的 UI 回归验证(2026-11-10)
  4. *
  5. * 验证点:
  6. * ① 图文被容器拆成两轮时,两轮**各自**计时,先结束的那轮定格不倒退
  7. * ② 同一时间不出现两条「正在思考 / 等待回复」占位
  8. * ③ 页面无未捕获错误
  9. *
  10. * 做法:真实发送「图 + 文字」,每 500ms 采样一次页面上的计时读数与占位文案,
  11. * 观察读数序列是否**单调不减**(倒退就是本次要修的 bug)。
  12. */
  13. import { chromium } from 'playwright';
  14. import { mkdir } from 'node:fs/promises';
  15. import { dirname, resolve } from 'node:path';
  16. import { fileURLToPath } from 'node:url';
  17. const here = dirname(fileURLToPath(import.meta.url));
  18. const root = resolve(here, '..');
  19. const outDir = resolve(root, 'test-results/interrupt-ui');
  20. const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
  21. const imgPath = process.argv[2];
  22. await mkdir(outDir, { recursive: true });
  23. const browser = await chromium.launch({ channel: 'chrome' });
  24. const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
  25. const errors = [];
  26. page.on('pageerror', (e) => errors.push(String(e)));
  27. await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
  28. await page.waitForTimeout(8000);
  29. const s = page.locator('text=使用HTML根据我这个').first();
  30. if (await s.count()) {
  31. await s.click();
  32. await page.waitForTimeout(5000);
  33. }
  34. // 发送:图 + 文字(复现容器的两轮)
  35. await page.locator('input[type="file"]').first().setInputFiles(imgPath);
  36. await page.waitForTimeout(1000);
  37. const box = page.locator('textarea').first();
  38. await box.click();
  39. await box.fill('用终端执行 date 命令,然后用一句话告诉我当前时间。');
  40. await page.keyboard.press('Enter');
  41. console.log('已发送图文,开始采样…');
  42. /** [{t, times:[..], waits:[..]}] */
  43. const samples = [];
  44. const t0 = Date.now();
  45. while (Date.now() - t0 < 120_000) {
  46. const snap = await page.evaluate(() => ({
  47. times: [...document.querySelectorAll('.head-time, .seg-time')].map((e) => e.textContent.trim()),
  48. waits: [...document.querySelectorAll('.seg-wait, .card-wait')].map((e) => e.textContent.trim()),
  49. }));
  50. samples.push({ at: ((Date.now() - t0) / 1000).toFixed(1), ...snap });
  51. await page.waitForTimeout(500);
  52. if (Date.now() - t0 > 30_000) {
  53. const noLive = !(await page.getByText('输出中').count());
  54. if (noLive) break;
  55. }
  56. }
  57. await page.screenshot({ path: `${outDir}/after.png`, fullPage: false });
  58. // 分析:只看最后一组头部读数(最新回合),检查是否单调不减
  59. const parse = (s) => parseInt(String(s).replace(/[^\d]/g, ''), 10);
  60. const series = samples
  61. .map((x) => x.times.map(parse).filter((n) => Number.isFinite(n)))
  62. .filter((a) => a.length > 0);
  63. let regressions = 0;
  64. for (let i = 1; i < series.length; i++) {
  65. for (let k = 0; k < Math.min(series[i].length, series[i - 1].length); k++) {
  66. if (series[i][k] < series[i - 1][k]) {
  67. regressions++;
  68. console.log(` ✗ 倒退:${series[i - 1][k]}s → ${series[i][k]}s(${samples[i].at}s 处)`);
  69. }
  70. }
  71. }
  72. // 占位重复:同一时刻出现多于 1 条「正在思考/等待回复」且文案相同
  73. const dup = samples.filter((x) => x.waits.length >= 2 && new Set(x.waits).size === 1).length;
  74. console.log(`\n采样 ${samples.length} 次,读数组 ${series.length} 组`);
  75. console.log(`读数倒退次数:${regressions}`);
  76. console.log(`重复占位采样数:${dup}`);
  77. console.log(`页面错误:${errors.length ? errors.slice(0, 3).join(' | ') : '无'}`);
  78. const pass = regressions === 0 && errors.length === 0;
  79. console.log(`\n${pass ? '✓ PASS' : '✗ FAIL'}:计时不退步 / 无页面错误`);
  80. await browser.close();
  81. process.exit(pass ? 0 : 1);