| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- #!/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);
- }
|