#!/usr/bin/env node /** * 群聊(伪群聊 · 多路派活)端到端冒烟 * * 用法: * npm start # 另开终端,dev server 已在 4200 * npm run smoke:groups # 默认走 /login?token= 自动登录 * DEV_TOKEN=r:xxx npm run smoke:groups * SMOKE_BASE=http://localhost:4300 npm run smoke:groups * * ## 前置条件 * * ① `npm start` 已运行(走 proxy.conf.js,与线上同源语义) * ② 一个有效的 `r:` SessionToken,且该账号名下**至少有一个群聊** * * ## 与旧版的差异(2026-11-06 重写) * * 旧脚本测的是 **2026-10-29 已下线的「工作组」**(`/desktop/groups`、 * `.group-row`、`.member-row`、角色编辑器)。当前群聊是**伪群聊**(ADR-007): * 路由 `/desktop/group/:groupId`,入口在左栏「群聊」分组。旧断言全部失效。 * * 本版只断言**能自动化稳定判断**的行为;涉及真实容器回复的项 * (后端异步、耗时不可控)标记为 SKIP 并说明原因,不伪装成通过。 */ import { chromium } from 'playwright'; import { resolve } from 'node:path'; import { existsSync, readdirSync, mkdirSync } from 'node:fs'; /* ------------------------------------------------------------------ */ /* 配置 */ /* ------------------------------------------------------------------ */ const BASE = process.env.SMOKE_BASE ?? 'http://localhost:4200'; const TOKEN = process.env.DEV_TOKEN ?? ''; const SHOT_DIR = resolve('test-results/smoke-groups'); mkdirSync(SHOT_DIR, { recursive: true }); if (!TOKEN) { console.error('❌ 缺少 DEV_TOKEN'); console.error(' 用法:DEV_TOKEN=r:xxxx npm run smoke:groups'); console.error(' 获取:登录 App → DevTools → localStorage.fmode_session_token'); process.exit(2); } /* ------------------------------------------------------------------ */ /* 断言辅助 */ /* ------------------------------------------------------------------ */ const results = []; function check(name, ok, detail = '') { results.push({ name, ok, skip: false, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' (' + detail + ')' : ''}`); } function skip(name, why) { results.push({ name, ok: true, skip: true, detail: why }); console.log(`SKIP ${name} (${why})`); } /* ------------------------------------------------------------------ */ /* 浏览器 */ /* ------------------------------------------------------------------ */ const launch = { headless: true }; const cache = resolve(process.env.LOCALAPPDATA ?? '', 'ms-playwright'); if (existsSync(cache)) { const inst = readdirSync(cache).filter((x) => /^chromium-\d+$/.test(x)).sort().reverse(); if (inst.length) launch.executablePath = resolve(cache, inst[0], 'chrome-win64', 'chrome.exe'); } const browser = await chromium.launch(launch); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const errs = []; page.on('pageerror', (e) => errs.push(e.message.slice(0, 120))); try { /* -------- 0. 登录 -------- */ await page.goto(`${BASE}/login?token=${encodeURIComponent(TOKEN)}`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForTimeout(3500); const loggedIn = await page.evaluate(() => !!localStorage.getItem('fmode_session_token')); check('自动登录', loggedIn); if (!loggedIn) throw new Error('登录失败,后续断言无意义'); /* -------- 1. 左栏群聊分组 -------- */ await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(8000); const groupSect = await page.evaluate(() => { const sect = document.querySelector('section.sect--group'); if (!sect) return null; return { hasTitle: sect.querySelector('.sect-name')?.textContent?.trim() === '群聊', count: Number(sect.querySelector('.sect-count')?.textContent?.trim() ?? '0'), rows: sect.querySelectorAll('.sess').length, hasNewBtn: !!sect.querySelector('[aria-label="新建群聊"]'), }; }); check('群聊分组存在', groupSect?.hasTitle === true); check('群聊行已渲染', (groupSect?.rows ?? 0) > 0, `${groupSect?.rows ?? 0} 行`); check('新建群聊入口', groupSect?.hasNewBtn === true); if (!groupSect || groupSect.rows === 0) { throw new Error('当前账号没有群聊,无法继续。请先建一个群并 @ 容器完成一轮对话。'); } /* -------- 2. 进入群聊 -------- */ await page.locator('section.sect--group .sess').first().click(); await page.waitForTimeout(7000); const state = await page.evaluate(() => ({ path: location.pathname, isGroupRoute: location.pathname.startsWith('/desktop/group/'), hasComposer: !!document.querySelector('app-composer textarea, .composer textarea'), turns: document.querySelectorAll('article.turn').length, hasHead: !!document.querySelector('.gchat-head'), inGroupSess: !!document.querySelector('section.sect--group .sess.active'), })); check('路由进入 /desktop/group/:id', state.isGroupRoute, state.path.slice(0, 60)); check('群聊页头渲染', state.hasHead); check('输入区可用', state.hasComposer); check('左栏群聊行高亮', state.inGroupSess); check('历史回合渲染', state.turns > 0, `${state.turns} 个回合`); await page.screenshot({ path: `${SHOT_DIR}/group.png` }); /* -------- 3. 不 @ 任何容器 → 拦截 -------- */ const ta = page.locator('app-composer textarea, .composer textarea').first(); /* * 🔴 测试文案里**不能出现** `@` 字符:`@xxx` 语法会把后面的词当成被 @ 的名字, * 于是走的是「非成员」分支而不是「未 @」分支(2026-11-06 已踩过这个坑)。 */ await ta.fill('这是一条没有点名任何容器的消息'); await page.waitForTimeout(600); await page.locator('.composer .send').click(); await page.waitForTimeout(1500); const noMention = await page.evaluate(() => { const el = document.querySelector('.send-err[role="alert"]'); return { shown: !!el, text: el?.textContent?.trim() ?? '' }; }); check('未 @ 时拦截并提示', noMention.shown && /@\s*要响应/.test(noMention.text), noMention.text.slice(0, 40)); /* -------- 4. @ 不在群里的名字 → 标红 -------- */ await ta.fill('@这个名字肯定不存在 做点什么'); await page.waitForTimeout(1200); const unknown = await page.evaluate(() => ({ badNames: [...document.querySelectorAll('.bad-name')].map((e) => e.textContent.trim()), err: document.querySelector('.send-err[role="alert"]')?.textContent?.trim() ?? '', })); check( '@ 非成员时标红提示', unknown.badNames.length > 0 || /不在群里/.test(unknown.err), unknown.badNames.join('/') || unknown.err.slice(0, 40), ); /* -------- 5. @ 自动补全 -------- */ await ta.fill('@'); await page.waitForTimeout(1000); const mentionCount = await page.locator('.mention-item').count(); check('@ 自动补全弹出候选', mentionCount > 0, `${mentionCount} 个候选`); if (mentionCount > 0) { const first = await page.locator('.mention-item').first().textContent(); await page.locator('.mention-item').first().click(); await page.waitForTimeout(800); const draft = await ta.inputValue(); check('@ 回填到输入框', draft.trim().startsWith('@'), draft.trim().slice(0, 30)); check('回填内容与候选项一致', draft.includes((first ?? '').trim().slice(0, 4)), `候选=${(first ?? '').trim().slice(0, 12)}`); } await page.screenshot({ path: `${SHOT_DIR}/mention.png` }); /* -------- 6. 真实发送(依赖容器,只验证「发出」不验证「回复」) -------- */ skip('真实发送并收到容器回复', '依赖真实容器异步响应,耗时不可控;由人工验收覆盖'); /* -------- 7. 清空草稿,避免影响后续 -------- */ await ta.fill(''); await page.waitForTimeout(400); /* -------- 8. 移动端群聊页 -------- */ await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${BASE}/mobile/messages`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(6000); const mobileGroup = await page.evaluate(() => ({ hasGroupSection: /群聊/.test(document.body.innerText), route: location.pathname, })); check('移动端消息页显示群聊分组', mobileGroup.hasGroupSection); await page.screenshot({ path: `${SHOT_DIR}/mobile-messages.png` }); /* -------- 9. 全程无未捕获错误 -------- */ check('全程无未捕获页面错误', errs.length === 0, errs.slice(0, 2).join(' | ')); } catch (e) { check('用例执行未中断', false, e.message.slice(0, 120)); } finally { const passed = results.filter((r) => r.ok && !r.skip).length; const skipped = results.filter((r) => r.skip).length; const failed = results.filter((r) => !r.ok).length; console.log( `\n=== 结果: ${passed} PASS / ${failed} FAIL / ${skipped} SKIP(共 ${results.length})===`, ); if (errs.length) console.log('页面错误:', errs.slice(0, 3).join(' | ')); console.log(`截图:${SHOT_DIR}`); await browser.close(); process.exit(failed > 0 ? 1 : 0); }