smoke-groups.mjs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. #!/usr/bin/env node
  2. /**
  3. * 群聊(伪群聊 · 多路派活)端到端冒烟
  4. *
  5. * 用法:
  6. * npm start # 另开终端,dev server 已在 4200
  7. * npm run smoke:groups # 默认走 /login?token= 自动登录
  8. * DEV_TOKEN=r:xxx npm run smoke:groups
  9. * SMOKE_BASE=http://localhost:4300 npm run smoke:groups
  10. *
  11. * ## 前置条件
  12. *
  13. * ① `npm start` 已运行(走 proxy.conf.js,与线上同源语义)
  14. * ② 一个有效的 `r:` SessionToken,且该账号名下**至少有一个群聊**
  15. *
  16. * ## 与旧版的差异(2026-11-06 重写)
  17. *
  18. * 旧脚本测的是 **2026-10-29 已下线的「工作组」**(`/desktop/groups`、
  19. * `.group-row`、`.member-row`、角色编辑器)。当前群聊是**伪群聊**(ADR-007):
  20. * 路由 `/desktop/group/:groupId`,入口在左栏「群聊」分组。旧断言全部失效。
  21. *
  22. * 本版只断言**能自动化稳定判断**的行为;涉及真实容器回复的项
  23. * (后端异步、耗时不可控)标记为 SKIP 并说明原因,不伪装成通过。
  24. */
  25. import { chromium } from 'playwright';
  26. import { resolve } from 'node:path';
  27. import { existsSync, readdirSync, mkdirSync } from 'node:fs';
  28. /* ------------------------------------------------------------------ */
  29. /* 配置 */
  30. /* ------------------------------------------------------------------ */
  31. const BASE = process.env.SMOKE_BASE ?? 'http://localhost:4200';
  32. const TOKEN = process.env.DEV_TOKEN ?? '';
  33. const SHOT_DIR = resolve('test-results/smoke-groups');
  34. mkdirSync(SHOT_DIR, { recursive: true });
  35. if (!TOKEN) {
  36. console.error('❌ 缺少 DEV_TOKEN');
  37. console.error(' 用法:DEV_TOKEN=r:xxxx npm run smoke:groups');
  38. console.error(' 获取:登录 App → DevTools → localStorage.fmode_session_token');
  39. process.exit(2);
  40. }
  41. /* ------------------------------------------------------------------ */
  42. /* 断言辅助 */
  43. /* ------------------------------------------------------------------ */
  44. const results = [];
  45. function check(name, ok, detail = '') {
  46. results.push({ name, ok, skip: false, detail });
  47. console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' (' + detail + ')' : ''}`);
  48. }
  49. function skip(name, why) {
  50. results.push({ name, ok: true, skip: true, detail: why });
  51. console.log(`SKIP ${name} (${why})`);
  52. }
  53. /* ------------------------------------------------------------------ */
  54. /* 浏览器 */
  55. /* ------------------------------------------------------------------ */
  56. const launch = { headless: true };
  57. const cache = resolve(process.env.LOCALAPPDATA ?? '', 'ms-playwright');
  58. if (existsSync(cache)) {
  59. const inst = readdirSync(cache).filter((x) => /^chromium-\d+$/.test(x)).sort().reverse();
  60. if (inst.length) launch.executablePath = resolve(cache, inst[0], 'chrome-win64', 'chrome.exe');
  61. }
  62. const browser = await chromium.launch(launch);
  63. const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
  64. const page = await ctx.newPage();
  65. const errs = [];
  66. page.on('pageerror', (e) => errs.push(e.message.slice(0, 120)));
  67. try {
  68. /* -------- 0. 登录 -------- */
  69. await page.goto(`${BASE}/login?token=${encodeURIComponent(TOKEN)}`, {
  70. waitUntil: 'domcontentloaded',
  71. timeout: 30000,
  72. });
  73. await page.waitForTimeout(3500);
  74. const loggedIn = await page.evaluate(() => !!localStorage.getItem('fmode_session_token'));
  75. check('自动登录', loggedIn);
  76. if (!loggedIn) throw new Error('登录失败,后续断言无意义');
  77. /* -------- 1. 左栏群聊分组 -------- */
  78. await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
  79. await page.waitForTimeout(8000);
  80. const groupSect = await page.evaluate(() => {
  81. const sect = document.querySelector('section.sect--group');
  82. if (!sect) return null;
  83. return {
  84. hasTitle: sect.querySelector('.sect-name')?.textContent?.trim() === '群聊',
  85. count: Number(sect.querySelector('.sect-count')?.textContent?.trim() ?? '0'),
  86. rows: sect.querySelectorAll('.sess').length,
  87. hasNewBtn: !!sect.querySelector('[aria-label="新建群聊"]'),
  88. };
  89. });
  90. check('群聊分组存在', groupSect?.hasTitle === true);
  91. check('群聊行已渲染', (groupSect?.rows ?? 0) > 0, `${groupSect?.rows ?? 0} 行`);
  92. check('新建群聊入口', groupSect?.hasNewBtn === true);
  93. if (!groupSect || groupSect.rows === 0) {
  94. throw new Error('当前账号没有群聊,无法继续。请先建一个群并 @ 容器完成一轮对话。');
  95. }
  96. /* -------- 2. 进入群聊 -------- */
  97. await page.locator('section.sect--group .sess').first().click();
  98. await page.waitForTimeout(7000);
  99. const state = await page.evaluate(() => ({
  100. path: location.pathname,
  101. isGroupRoute: location.pathname.startsWith('/desktop/group/'),
  102. hasComposer: !!document.querySelector('app-composer textarea, .composer textarea'),
  103. turns: document.querySelectorAll('article.turn').length,
  104. hasHead: !!document.querySelector('.gchat-head'),
  105. inGroupSess: !!document.querySelector('section.sect--group .sess.active'),
  106. }));
  107. check('路由进入 /desktop/group/:id', state.isGroupRoute, state.path.slice(0, 60));
  108. check('群聊页头渲染', state.hasHead);
  109. check('输入区可用', state.hasComposer);
  110. check('左栏群聊行高亮', state.inGroupSess);
  111. check('历史回合渲染', state.turns > 0, `${state.turns} 个回合`);
  112. await page.screenshot({ path: `${SHOT_DIR}/group.png` });
  113. /* -------- 3. 不 @ 任何容器 → 拦截 -------- */
  114. const ta = page.locator('app-composer textarea, .composer textarea').first();
  115. /*
  116. * 🔴 测试文案里**不能出现** `@` 字符:`@xxx` 语法会把后面的词当成被 @ 的名字,
  117. * 于是走的是「非成员」分支而不是「未 @」分支(2026-11-06 已踩过这个坑)。
  118. */
  119. await ta.fill('这是一条没有点名任何容器的消息');
  120. await page.waitForTimeout(600);
  121. await page.locator('.composer .send').click();
  122. await page.waitForTimeout(1500);
  123. const noMention = await page.evaluate(() => {
  124. const el = document.querySelector('.send-err[role="alert"]');
  125. return { shown: !!el, text: el?.textContent?.trim() ?? '' };
  126. });
  127. check('未 @ 时拦截并提示', noMention.shown && /@\s*要响应/.test(noMention.text), noMention.text.slice(0, 40));
  128. /* -------- 4. @ 不在群里的名字 → 标红 -------- */
  129. await ta.fill('@这个名字肯定不存在 做点什么');
  130. await page.waitForTimeout(1200);
  131. const unknown = await page.evaluate(() => ({
  132. badNames: [...document.querySelectorAll('.bad-name')].map((e) => e.textContent.trim()),
  133. err: document.querySelector('.send-err[role="alert"]')?.textContent?.trim() ?? '',
  134. }));
  135. check(
  136. '@ 非成员时标红提示',
  137. unknown.badNames.length > 0 || /不在群里/.test(unknown.err),
  138. unknown.badNames.join('/') || unknown.err.slice(0, 40),
  139. );
  140. /* -------- 5. @ 自动补全 -------- */
  141. await ta.fill('@');
  142. await page.waitForTimeout(1000);
  143. const mentionCount = await page.locator('.mention-item').count();
  144. check('@ 自动补全弹出候选', mentionCount > 0, `${mentionCount} 个候选`);
  145. if (mentionCount > 0) {
  146. const first = await page.locator('.mention-item').first().textContent();
  147. await page.locator('.mention-item').first().click();
  148. await page.waitForTimeout(800);
  149. const draft = await ta.inputValue();
  150. check('@ 回填到输入框', draft.trim().startsWith('@'), draft.trim().slice(0, 30));
  151. check('回填内容与候选项一致', draft.includes((first ?? '').trim().slice(0, 4)), `候选=${(first ?? '').trim().slice(0, 12)}`);
  152. }
  153. await page.screenshot({ path: `${SHOT_DIR}/mention.png` });
  154. /* -------- 6. 真实发送(依赖容器,只验证「发出」不验证「回复」) -------- */
  155. skip('真实发送并收到容器回复', '依赖真实容器异步响应,耗时不可控;由人工验收覆盖');
  156. /* -------- 7. 清空草稿,避免影响后续 -------- */
  157. await ta.fill('');
  158. await page.waitForTimeout(400);
  159. /* -------- 8. 移动端群聊页 -------- */
  160. await page.setViewportSize({ width: 390, height: 844 });
  161. await page.goto(`${BASE}/mobile/messages`, { waitUntil: 'domcontentloaded' });
  162. await page.waitForTimeout(6000);
  163. const mobileGroup = await page.evaluate(() => ({
  164. hasGroupSection: /群聊/.test(document.body.innerText),
  165. route: location.pathname,
  166. }));
  167. check('移动端消息页显示群聊分组', mobileGroup.hasGroupSection);
  168. await page.screenshot({ path: `${SHOT_DIR}/mobile-messages.png` });
  169. /* -------- 9. 全程无未捕获错误 -------- */
  170. check('全程无未捕获页面错误', errs.length === 0, errs.slice(0, 2).join(' | '));
  171. } catch (e) {
  172. check('用例执行未中断', false, e.message.slice(0, 120));
  173. } finally {
  174. const passed = results.filter((r) => r.ok && !r.skip).length;
  175. const skipped = results.filter((r) => r.skip).length;
  176. const failed = results.filter((r) => !r.ok).length;
  177. console.log(
  178. `\n=== 结果: ${passed} PASS / ${failed} FAIL / ${skipped} SKIP(共 ${results.length})===`,
  179. );
  180. if (errs.length) console.log('页面错误:', errs.slice(0, 3).join(' | '));
  181. console.log(`截图:${SHOT_DIR}`);
  182. await browser.close();
  183. process.exit(failed > 0 ? 1 : 0);
  184. }