From 5ed14c2aac2b6604a79d5ab12b5ccd6e8246a7f5 Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Thu, 23 Apr 2026 17:21:42 +0800 Subject: [PATCH] =?UTF-8?q?docs(meeting):=20Task=2016=20E2E=20=E5=89=A7?= =?UTF-8?q?=E6=9C=AC=E8=90=BD=E7=9B=98=20+=20.gitignore=20=E7=B2=BE?= =?UTF-8?q?=E7=BB=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright MCP 4 场景剧本(.playwright-mcp/task16/): - README.md:总览 + 账号/环境/资源断言指南 - scenarios/01-create-and-join-by-code.md:创建→会议号加入→媒体打通→结束,含 Redis/MySQL/media-server 三维资源断言 - scenarios/02-invite-link-with-password.md:邀请链接带密码 + P0-4 "密码不走 URL query" 回归 - scenarios/03-notify-quickjoin.md:通知中心「立即加入」跳转 + 过期卡片降级 - scenarios/04-host-toolkit-and-transfer.md:主持人四件套 + 宽限期重连/到期自动转让(P1-1 原子性 + P1-7 处理锁验证) - scenarios/05-reopen-5times-resource-cleanup.md:反复开关 5 次 + 前端 heap 快照/ Redis 累积对比/ media-server 观测端点对比,验证 Step A(f5ae095)资源清理修复 手工回归点(manual-regression.md):17 项 MCP 难以稳定覆盖的交互场景 (HTTPS 自签 / 跨网段 iOS / 网络抖动 / 物理返回 / 跨 tab 并发 / 摄像头抢占 等) .gitignore 调整: - 放行 .playwright-mcp/task16/ 下的 Markdown 剧本文档进入版本控制 - 仍忽略运行时产物(*.log/*.png/*.yml/截图) - task15/ 保持未跟踪状态,避免历史截图噪声入库 Made-with: Cursor --- .gitignore | 15 +- .playwright-mcp/task16/README.md | 91 +++++++++++ .playwright-mcp/task16/manual-regression.md | 39 +++++ .../scenarios/01-create-and-join-by-code.md | 147 ++++++++++++++++++ .../scenarios/02-invite-link-with-password.md | 91 +++++++++++ .../task16/scenarios/03-notify-quickjoin.md | 86 ++++++++++ .../scenarios/04-host-toolkit-and-transfer.md | 139 +++++++++++++++++ .../05-reopen-5times-resource-cleanup.md | 130 ++++++++++++++++ 8 files changed, 737 insertions(+), 1 deletion(-) create mode 100644 .playwright-mcp/task16/README.md create mode 100644 .playwright-mcp/task16/manual-regression.md create mode 100644 .playwright-mcp/task16/scenarios/01-create-and-join-by-code.md create mode 100644 .playwright-mcp/task16/scenarios/02-invite-link-with-password.md create mode 100644 .playwright-mcp/task16/scenarios/03-notify-quickjoin.md create mode 100644 .playwright-mcp/task16/scenarios/04-host-toolkit-and-transfer.md create mode 100644 .playwright-mcp/task16/scenarios/05-reopen-5times-resource-cleanup.md diff --git a/.gitignore b/.gitignore index b875218..ab9dfe3 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,20 @@ coverage/ *.coverprofile # --- Playwright 截图与 MCP 快照 --- +# 用法规则:.playwright-mcp 仅默认忽略"运行时产物"(截图/日志/page 快照 yml), +# 通过精确白名单放行"剧本文档"(如 task16/ 下的 Markdown 回归剧本)进入版本控制。 +# 注意:gitignore 语义要求父目录不能整目录排除,否则子目录 ! 规则会失效 *.png !design-system/**/*.png -.playwright-mcp/ +# 默认忽略所有运行时产物 +.playwright-mcp/*.log +.playwright-mcp/*.yml +.playwright-mcp/*.png +.playwright-mcp/*.txt +.playwright-mcp/*.jpg !.playwright-mcp/.gitkeep +# Task 16 E2E 剧本文档(task15 仍保持未跟踪,避免历史截图噪声) +.playwright-mcp/task16/**/screenshots/ +.playwright-mcp/task16/**/*.log +.playwright-mcp/task16/**/*.png +.playwright-mcp/task16/**/*.yml diff --git a/.playwright-mcp/task16/README.md b/.playwright-mcp/task16/README.md new file mode 100644 index 0000000..6ed2c29 --- /dev/null +++ b/.playwright-mcp/task16/README.md @@ -0,0 +1,91 @@ +# Task 16 Playwright MCP E2E 剧本 + +> 生成时间:2026-04-23(UTC+8) +> 回归范围:Phase 2e-2 Task 16 —— E2E 总回归 + 资源清理专项 +> 环境:macOS 25.3.0 / Chromium(Playwright MCP)/ 1440×900 桌面视口 +> 三件套:`go-service :8085`、`media-server :3300`、`frontend :5173`(HTTPS 自签) + +## 场景矩阵 + +| # | 文件 | 覆盖设计文档章节 | 验证目标 | 资源断言 | +|---|---|---|---|---| +| 01 | [`scenarios/01-create-and-join-by-code.md`](scenarios/01-create-and-join-by-code.md) | §6.1 / §6.3 | 创建即时会议 → 会议号加入 → 双向音视频打通 → 主持人结束 | Router/Transport/Producer/Consumer 全量创建 + CloseRouter 级联释放 | +| 02 | [`scenarios/02-invite-link-with-password.md`](scenarios/02-invite-link-with-password.md) | §6.2 / §6.4 | 邀请链接带密码 → 受邀人免密加入 | `meeting_invites` 表 `consumed_at` 更新;密码不出现在 URL query | +| 03 | [`scenarios/03-notify-quickjoin.md`](scenarios/03-notify-quickjoin.md) | §6.4 | 通知中心「立即加入」按钮跳转 | `meeting_invitation` 通知类型 + `expired_at` 边界 | +| 04 | [`scenarios/04-host-toolkit-and-transfer.md`](scenarios/04-host-toolkit-and-transfer.md) | §6.5 / §6.6 | 主持人四件套(请静音/开麦/转让/踢出)+ host 宽限期自动转让 | `meeting_participants.role` 原子转让;`host_grace` Redis key + 处理锁 SETNX | +| 05 | [`scenarios/05-reopen-5times-resource-cleanup.md`](scenarios/05-reopen-5times-resource-cleanup.md) | §5.4 / §资源清理专项 | **反复开关 5 次会议,验证资源无泄漏** | `echo:meeting:resource:*` / `echo:meeting:member_state:*` / `echo:meeting:host_grace:*` / `echo:meeting:empty_ttl:*` 全部不残留;前端 `_engine` / `AudioContext` / `setInterval` 全部释放 | + +## 测试账号 + +沿用 Task 15 账号体系(固定 `user_id` 便于断言): + +| 账号 | 密码 | user_id | 角色 | +|---|---|---:|---| +| `task15_a` | `Task15@2026` | 55 | 主持人 Alice | +| `task15_b` | `Task15@2026` | 56 | 参会人 Bob | +| `task15_c` | `Task15@2026` | 57 | 参会人 Carol(场景 04 需要第三人验证 host 宽限期转让目标) | + +> 账号若不存在可用 `POST /api/v1/auth/register` 脚本化创建;密码策略要求大写 + 特殊字符。 + +## 通用前置条件 + +1. **三件套已就绪**: + ```bash + # backend/go-service + go run cmd/allinone/main.go + # media-server + cd media-server && npm run dev + # frontend + cd frontend && npm run dev:h5:https + ``` +2. **DB/Redis 干净**: + ```bash + # MySQL:确认 meeting_rooms / meeting_participants / meeting_invites 无遗留 active 会话 + mysql -e "UPDATE meeting_rooms SET status='ended' WHERE status='active'" echochat + # Redis:清掉可能的残留 + redis-cli --scan --pattern 'echo:meeting:*' | xargs -r redis-cli DEL + ``` +3. **浏览器**:Chromium 允许 `localhost` 自签证书(`chrome://flags/#allow-insecure-localhost`),并授予 `camera` / `microphone` 权限。 +4. **控制台期望**:无 `RTCPeerConnection error` / `NotReadableError` / `ResourceNotOwned`。 + +## 运行方式 + +每个剧本文件的 **"MCP 执行脚本"** 段落直接复制给 Playwright MCP Agent(Cursor Chat 中 `Use playwright-mcp` 触发): + +```text +请按照 @.playwright-mcp/task16/scenarios/01-create-and-join-by-code.md 的 "MCP 执行脚本" 章节逐步执行并逐条断言。 +``` + +Agent 会用 `browser_navigate` / `browser_click` / `browser_fill_form` / `browser_evaluate` 完成剧本,每步结果以截图 + 控制台日志 + Pinia state 快照形式落盘到 `.playwright-mcp/task16//`。 + +## 资源断言脚本 + +每个场景结束后需要运行的资源检查(用于证明 Step A 的资源清理修复生效): + +```bash +# Redis:会议期间 key 清单(应仅含活跃会议,结束后应清零) +redis-cli KEYS 'echo:meeting:resource:*' +redis-cli KEYS 'echo:meeting:member_state:*' +redis-cli KEYS 'echo:meeting:host_grace:*' +redis-cli KEYS 'echo:meeting:empty_ttl:*' + +# MySQL:所有 active 会议 +mysql -e "SELECT id, room_code, status, host_id FROM meeting_rooms WHERE status='active'" echochat + +# media-server 观测端点:Router / Transport / Producer / Consumer 汇总 +curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq .stats +``` + +## 产出物 + +每个场景执行后需要落盘: + +- `scenarios/XX-*/screenshots/`:关键 UI 截图(入会前、打通后、结束后) +- `scenarios/XX-*/console.log`:浏览器控制台归档 +- `scenarios/XX-*/result.md`:逐条断言结果(✅/❌ + 证据链接) + +所有 5 场景跑完后在 `docs/reports/test-report-phase2e-2-meeting.md` 汇总。 + +## 手动回归点 + +Playwright MCP 难以覆盖的交互场景归档在 [`manual-regression.md`](manual-regression.md),由开发者手工过一遍并记录结论。 diff --git a/.playwright-mcp/task16/manual-regression.md b/.playwright-mcp/task16/manual-regression.md new file mode 100644 index 0000000..bd4fe64 --- /dev/null +++ b/.playwright-mcp/task16/manual-regression.md @@ -0,0 +1,39 @@ +# Task 16 手动回归点清单 + +> Playwright MCP 难以稳定覆盖的交互场景,由开发者手工在 HTTPS 环境过一遍并记录结论。 +> 状态列:✅ 通过 / ❌ 失败 / ⏳ 待跑 / ➖ 不适用 + +| # | 场景 | 操作步骤 | 期望 | 状态 | 备注 | +|---|---|---|---|---|---| +| M1 | HTTPS 自签证书首次允许 | Chromium 打开 `https://localhost:5173/` → 点「高级」→「继续访问」→ 依次放行摄像头/麦克风 | uni-app 首页正常渲染;控制台无 Mixed Content 告警 | ⏳ | Playwright 多 context 需预注入证书信任 | +| M2 | iOS Safari 真机跨网段入会 | 手机浏览器(Safari / 微信内置)访问 `https://<局域网 IP>:5173` → 扫码加入会议 | 音视频双向打通;`NetworkBadge` 显示 3 条绿波浪 | ⏳ | iOS Safari `srcObject` 行为与 Chromium 存在差异 | +| M3 | 网络抖动:WS 断开 20s 后恢复 | 会议中开发者工具切到 Offline,等 20s 后恢复 Online | 无声无画面约 5s 后自动 resume;`remoteConsumers` 索引无漂移;不重复出现「其他用户」 | ⏳ | 关联 Task 14 `reconnect` 路径 | +| M4 | 网络抖动:WS 断开 > 2 分钟 | Offline 维持 130s 后 Online | 被服务端视为宽限到期,Alice(主持人)转让给 Bob;本地 UI 跳回首页 | ⏳ | 场景 04 路径 B 的手工版 | +| M5 | 物理返回按钮离会 | 会议进行中按浏览器 / 手机物理返回 | `onUnload` 触发 `leave()` → REST `leaveRoom` 成功 → 本地资源释放 | ⏳ | 关注 `pages/meeting/room.vue` onUnload | +| M6 | 结束页停留久后返回 | 主持人结束 → 停在「会议已结束」提示页 5 分钟 → 再点返回 | `exitEndedRoom` 触发,pinia state 重置;再次入会无残留 | ⏳ | **Step A F1 核心回归** | +| M7 | 跨 tab 并发入同一会议 | Alice tab 入会后复制 URL 到新 tab 再次打开 | 新 tab 命中"已在此会议中"保护;点提示按 `cleanupStaleMeetings` 自动清理后重试入会 | ⏳ | 关联 Task 11 `ErrAlreadyInMeeting` | +| M8 | 摄像头被系统抢占 | 入会后用其他应用抢占摄像头(如 FaceTime) | UI 显示占位封面 + 「摄像头被占用」toast;会议不崩溃;释放后可重新开摄像头 | ⏳ | `NotReadableError` 分支 | +| M9 | 浏览器关闭/崩溃恢复 | 入会后直接 kill 浏览器进程,重启后重新登录并入同会 | 残留 participant 通过 `cleanupStaleMeetings` + 自动重试加入恢复 | ⏳ | 与 M7 互补 | +| M10 | Chat 面板 - 长消息发送 | 在会议中聊天面板发送 5000 字消息 | 服务端拒绝 `msg too long`(期望 P2 新增限制)或前端提前拦截 | ⏳ | 对应 P2-7 | +| M11 | 邀请 token 超时但会议仍存在 | 手工 SQL 将 `meeting_invites.expired_at` 改为过去时间 → 访问邀请链接 | 预览页显示「邀请已过期」,不允许加入;点「重新申请」可弹窗 | ⏳ | 对应 §6.4 | +| M12 | 主持人关闭摄像头但麦克风保留 | 会议中点视频开关 → 仅摄像头关闭 | 自己画面占位 + 工具栏视频按钮变灰;音频仍正常;其他成员成员面板图标同步 | ⏳ | 对应 Task 15 状态 | +| M13 | 静音氛围色 | Alice + Bob + Carol 全员关麦 | 工具栏背景切蓝色渐变;有任何一人开麦则复位 | ⏳ | Task 15 原创特色 4 | +| M14 | 说话者流光轮廓 | Bob 说话 ≥0.5s | Bob 视频 tile 出现流光边框;停 1s 后消失 | ⏳ | Task 15 原创特色 1 | +| M15 | 图钉 / 浮窗切换 | 桌面端点自视频浮窗的图钉图标 | 自视频从浮窗切回 2×2 宫格格子;再点一次返回浮窗 | ⏳ | Task 15 原创特色 3 | +| M16 | 无网情况下直接访问会议 | 断网后访问会议首页 | 友好错误提示;Retry 可恢复 | ⏳ | 通用容错 | +| M17 | 高延迟网络(150ms+ + 1% 丢包) | DevTools Throttling 选 `Slow 3G`;入会并说话 | 音视频可连通(降级);不崩溃;`NetworkBadge` 波浪数降为 1-2 | ⏳ | 生产近似网络 | + +## 手工验证数据记录模板 + +每次跑完某一条,在 `docs/reports/test-report-phase2e-2-meeting.md` 填入: + +```md +### M<编号> 验证记录 + +- 执行时间:2026-04-xx HH:MM +- 环境:Chromium 126 / macOS 25.3.0 / HTTPS 自签 +- 结论:✅/❌ +- 截图证据:`.playwright-mcp/task16/manual/M<编号>.png` +- 异常描述(若 ❌):... +- 修复动作:... +``` diff --git a/.playwright-mcp/task16/scenarios/01-create-and-join-by-code.md b/.playwright-mcp/task16/scenarios/01-create-and-join-by-code.md new file mode 100644 index 0000000..9dd6035 --- /dev/null +++ b/.playwright-mcp/task16/scenarios/01-create-and-join-by-code.md @@ -0,0 +1,147 @@ +# 场景 01:创建会议 → 会议号加入 → 双向音视频打通 → 主持人结束 + +## 目标 + +- 验证 `POST /api/v1/meeting/rooms`(创建)+ `POST /api/v1/meeting/rooms/:code/join`(加入)+ mediasoup 信令全链路打通 +- 覆盖设计 §6.1(REST 建房)、§6.3(WS 信令 11 事件)、§6.6(NodeClient 媒体编排) +- 配套断言 Step A 资源清理:结束会议后 Redis / MySQL / media-server 全部归零 + +## 前置条件 + +依 [`../README.md`](../README.md) 通用前置;额外确认: + +- 两个浏览器上下文(MCP 多 tab)分别以 `task15_a` / `task15_b` 登录 +- Alice tab 为主持人,Bob tab 为参会人 + +## MCP 执行脚本 + +### 步骤 1:Alice 登录并创建即时会议 + +1. `browser_navigate` → `https://localhost:5173/#/pages/auth/login` +2. `browser_fill_form` → 账号 `task15_a` / 密码 `Task15@2026` → 点 "登录" +3. 断言首页进入「消息」tab +4. 底部切换到「会议」tab(`pages/meeting/index`) +5. 点「创建即时会议」 +6. 设备预览页:默认勾选麦克风 + 摄像头;点「立即进入」 +7. **进入 `pages/meeting/room`**,截图 → `01-alice-created.png` +8. 读取 URL query 得到 `roomCode`,记录到上下文变量 `$ROOM_CODE` + +### 步骤 2:Bob 通过会议号加入 + +1. 开第二个 browser context;`browser_navigate` → `https://localhost:5173/#/pages/auth/login` +2. 以 `task15_b` 登录 +3. 切到「会议」tab,点「通过会议号加入」 +4. 输入 `$ROOM_CODE` +5. 预览页 → 「立即加入」 +6. 截图 → `02-bob-joined.png` + +### 步骤 3:双向音视频打通断言 + +- Alice tab 成员面板应展示 2 个成员,Bob 的 AV 图标为蓝色(开启状态) +- Bob tab 成员面板同理 +- Alice 的视频宫格应能看到 Bob 的视频流(`VideoTile[user_id=56] video.srcObject` 非空) +- 控制台验证 mediasoup 关键日志: + - `[MediaEngine] producer 创建成功 kind=audio` + - `[MediaEngine] producer 创建成功 kind=video` + - `[MediaEngine] consumer 创建成功` +- `browser_evaluate`: + ```js + const store = window.__pinia?.state?.value?.meeting + return { + participantCount: store.participants.length, + localAudio: store.localAudioEnabled, + localVideo: store.localVideoEnabled, + remoteUserCount: Object.keys(store.remoteConsumers).length, + } + ``` + 期望:`participantCount=2`、`localAudio=true`、`localVideo=true`、`remoteUserCount=1` + +### 步骤 4:资源创建态断言 + +在第三个终端执行: + +```bash +# media-server 应有 1 Router + 4 Transport(2 users × send/recv)+ 4 Producer + 4 Consumer +curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq .stats + +# Redis 应有两个用户的资源追踪 Set +redis-cli KEYS "echo:meeting:resource:$ROOM_CODE:*" +# 期望输出:echo:meeting:resource:xxx:55 / echo:meeting:resource:xxx:56 +# 每个 SMEMBERS 应有 5 条(transport×2 + producer×2 + consumer×2 之类,具体取决于 producer.new 收敛顺序) +redis-cli SMEMBERS "echo:meeting:resource:$ROOM_CODE:55" +redis-cli SMEMBERS "echo:meeting:resource:$ROOM_CODE:56" +``` + +### 步骤 5:Alice 结束会议 + +1. Alice tab 工具栏 → 「结束会议」→ 弹窗确认「主持人结束」 +2. 截图 → `03-alice-ended.png`(页面转为「会议已结束」提示页) +3. Bob tab 应收到 WS `meeting.room.ended` 广播,展示「会议已结束」toast,1.2s 后跳回会议首页 +4. 截图 → `04-bob-received-ended.png` + +### 步骤 6:资源释放断言(核心验证 Step A) + +```bash +# 1. Redis 清理验证(P1-4 + Step A B1/B3 的核心断言) +redis-cli KEYS "echo:meeting:resource:$ROOM_CODE:*" # 期望:(empty array) +redis-cli KEYS "echo:meeting:member_state:$ROOM_CODE:*" # 期望:(empty array) +redis-cli KEYS "echo:meeting:host_grace:$ROOM_CODE" # 期望:(nil) +redis-cli KEYS "echo:meeting:empty_ttl:$ROOM_CODE" # 期望:(nil) + +# 2. MySQL 状态验证 +mysql -e "SELECT status, ended_reason FROM meeting_rooms WHERE room_code='$ROOM_CODE'" echochat +# 期望:status=ended, ended_reason=host_ended + +mysql -e "SELECT user_id, left_at, left_reason FROM meeting_participants WHERE room_id=(SELECT id FROM meeting_rooms WHERE room_code='$ROOM_CODE')" echochat +# 期望:Alice + Bob 都有 left_at ≠ NULL, left_reason='host_end' + +# 3. media-server Router / Transport / Producer / Consumer 全部归零 +curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq '.stats | .routers, .transports, .producers, .consumers' +# 期望:对应 $ROOM_CODE 的 Router 已销毁;整体 stats 可能非 0(其他并行测试)但本场 Router 必须不存在 +``` + +### 步骤 7:前端资源释放断言 + +在 Alice tab 执行 `browser_evaluate`: + +```js +// 结束页停留时的 state 快照 +const store = window.__pinia?.state?.value?.meeting +return { + localState: store.localState, // 期望:'ended' + engineExists: !!window.__engine, // 期望:false(_cleanupMedia 已置空) + participantCount: store.participants.length, // 结束页保留渲染数据,非 0 +} +``` + +然后手动返回首页(模拟 `onUnload` 触发): + +1. `browser_navigate_back` 或点结束页的「返回首页」按钮 +2. 再次 `browser_evaluate` 断言 pinia state: + ```js + const store = window.__pinia?.state?.value?.meeting + return { + localState: store.localState, // 期望:'idle' + participantCount: store.participants.length, // 期望:0 + currentRoom: store.currentRoom, // 期望:null + } + ``` + +## 预期结果汇总 + +| 断言维度 | 预期 | +|---|---| +| WS 信令 | `room.join` / `producer.new` / `consume` 全链路无异常 | +| 双向媒体 | 两端视频宫格均能播放对方画面,`localAudioEnabled=true` 且 `remoteConsumers` 含对方 | +| Redis 清理 | `resource:*` / `member_state:*` / `host_grace` / `empty_ttl` 全空 | +| DB 一致 | `meeting_rooms.status=ended`、两行 `meeting_participants.left_at` 非空 | +| media-server | 对应 Router 已删除,level transport/producer/consumer 观察端点无残留 | +| 前端 | `_engine=null` / `_pendingBroadcastTimers.size=0`;`onUnload` 后 pinia state 全部 reset | + +## 失败排查 + +| 现象 | 可能原因 | 排查点 | +|---|---|---| +| Bob 看不到 Alice 的画面 | `pushExistingRoomState` 同步性(Task 16 P1-8 修复) | `logs.Info` 搜 `room.join` 是否先于 `member.joined` | +| 结束后 Redis `resource:*` 仍存 | `EndRoom` 未走 `cleanupRoomRedisResidual`(Step A B1 回归) | `grep "cleanupRoomRedisResidual" backend/go-service/app/meeting/service/meeting_service.go` | +| `localState` 卡在 `connected` | `_onRoomEnded` 未触发 | 检查 Bob tab 是否收到 `meeting.room.ended` WS 事件(网络面板 frame) | diff --git a/.playwright-mcp/task16/scenarios/02-invite-link-with-password.md b/.playwright-mcp/task16/scenarios/02-invite-link-with-password.md new file mode 100644 index 0000000..9888201 --- /dev/null +++ b/.playwright-mcp/task16/scenarios/02-invite-link-with-password.md @@ -0,0 +1,91 @@ +# 场景 02:邀请链接加入(带密码) + +## 目标 + +- 验证 `POST /api/v1/meeting/rooms/:code/invites`(签发邀请)+ `POST /api/v1/meeting/invites/:token/consume`(消费邀请) +- 覆盖设计 §6.2(邀请 token 7 天有效期)、§6.4(邀请链接 URL) +- **验证 Task 16 P0-4 修复**:密码不出现在 URL query,仅通过内存 state 或 Sec-Fetch 头传递 + +## 前置条件 + +依 [`../README.md`](../README.md) 通用前置。 + +## MCP 执行脚本 + +### 步骤 1:Alice 创建带密码的会议 + +1. 以 `task15_a` 登录 +2. 会议首页 → 「创建会议(自定义)」→ 切换到有密码模式 +3. 填充: + - 会议主题:`Task16 邀请回归` + - 密码:`invite@2026` + - 其他留默认 +4. 点「创建并进入」 +5. 记录 `$ROOM_CODE` + +### 步骤 2:Alice 生成邀请链接 + +1. 工具栏 → 「邀请」 → 「复制邀请链接」 +2. 从剪贴板读取链接;格式示例:`https://localhost:5173/#/pages/meeting/preview?mode=join&code=xxx&invite_token=yyy` +3. **断言 URL query 不含 password 明文**(P0-4 核心断言): + ```js + // browser_evaluate + const link = await navigator.clipboard.readText() + return { link, hasPassword: /password=/i.test(link) } + ``` + 期望:`hasPassword=false` + +### 步骤 3:Bob 通过邀请链接加入 + +1. 开 Bob tab,已登录 `task15_b` +2. `browser_navigate` → 步骤 2 拿到的链接 +3. 预览页展示「即将加入 `$ROOM_CODE` / 邀请人 Alice」+ 设备选择面板 +4. 点「立即加入」 +5. 期望:**无需输入密码**(邀请 token 已授权) +6. 截图 → `02-bob-joined-via-invite.png` + +### 步骤 4:资源与 DB 断言 + +```bash +# meeting_invites.consumed_at 应被填充 +mysql -e "SELECT invite_token, invitee_id, consumed_at FROM meeting_invites \ + WHERE room_id=(SELECT id FROM meeting_rooms WHERE room_code='$ROOM_CODE')" echochat + +# meeting_participants 应有 Bob 行,joined_method='invite' +mysql -e "SELECT user_id, joined_method, invite_token FROM meeting_participants \ + WHERE room_id=(SELECT id FROM meeting_rooms WHERE room_code='$ROOM_CODE')" echochat +``` + +期望: +- `consumed_at` 时间戳为最近几秒 +- Bob 行 `joined_method='invite'`,`invite_token` 与邀请链接一致 + +### 步骤 5:直接输入会议号(不含邀请 token)应要求密码 + +1. 第三个 tab 以 `task15_c` 登录 +2. 会议首页 → 「通过会议号加入」 → 输入 `$ROOM_CODE` +3. 预览页 / 加入接口应返回 `ErrPasswordRequired` → UI 弹出密码输入框 +4. 输入错误密码 `wrong`,断言返回 401 + UI 提示 +5. 输入正确密码 `invite@2026`,成功加入 +6. 截图 → `03-carol-password-join.png` + +### 步骤 6:资源清理(场景结束) + +Alice 结束会议;执行 [`01-create-and-join-by-code.md`](01-create-and-join-by-code.md) 步骤 6-7 的 Redis/DB 断言。 + +## 预期结果汇总 + +| 断言 | 预期 | +|---|---| +| 邀请 URL 不含 password | `hasPassword=false` | +| Bob 免密通过邀请加入 | HTTP 200 + WS `room.join` ACK | +| `meeting_invites.consumed_at` | 时间戳 ≈ 加入瞬间 | +| Carol 直接加入需要密码 | 401 → 输入后成功 | + +## 失败排查 + +| 现象 | 定位 | +|---|---| +| URL 仍含 `password=` | P0-4 回归:`frontend/src/store/meeting.js` `draftJoinPayload` 是否将密码存内存 | +| Bob 使用邀请仍被要求密码 | `meeting_service.JoinRoom` 的 invite token 分支 | +| `consumed_at` 仍为 NULL | `MeetingInviteDAO.Consume` 未调 / 事务回滚 | diff --git a/.playwright-mcp/task16/scenarios/03-notify-quickjoin.md b/.playwright-mcp/task16/scenarios/03-notify-quickjoin.md new file mode 100644 index 0000000..509af71 --- /dev/null +++ b/.playwright-mcp/task16/scenarios/03-notify-quickjoin.md @@ -0,0 +1,86 @@ +# 场景 03:通知中心「立即加入」按钮跳转 + +## 目标 + +- 验证邀请被签发时,受邀人通知中心实时刷新 + 卡片双按钮(「立即加入」/「稍后」) +- 覆盖设计 §6.4(邀请通知类型 `meeting_invitation`) +- 过期卡片应渲染为 disabled 单按钮「邀请已过期」 + +## 前置条件 + +依 [`../README.md`](../README.md) 通用前置;Alice 已在会议中(复用场景 02 的 `$ROOM_CODE`),或新开一场会议。 + +## MCP 执行脚本 + +### 步骤 1:Alice 邀请 Bob + +1. Alice tab 工具栏 → 「邀请」 → 「发送给联系人」 +2. 联系人弹窗选择 Bob → 确认 +3. 期望:UI toast「邀请已送达」,关闭弹窗 + +### 步骤 2:Bob 通知中心实时接收 + +1. Bob tab(已登录 `task15_b`)→ 底部切到「通知」tab +2. 期望:最顶部新增卡片 + - 类型标识:`会议邀请` + - 内容:`Alice 邀请你加入会议 $ROOM_CODE` + - 双按钮:**「立即加入」 + 「稍后」** +3. 截图 → `01-notify-new-invite.png` +4. `browser_evaluate` 断言: + ```js + const store = window.__pinia?.state?.value?.notify + const latest = store.list[0] + return { + type: latest.notify_type, // 期望:'meeting_invitation' + expired: latest.expired_at < Date.now()/1000, // 期望:false + hasButtons: !!latest.ext?.room_code // 期望:true + } + ``` + +### 步骤 3:点击「立即加入」跳转 + +1. 点「立即加入」 +2. 期望跳转 `pages/meeting/preview?mode=join&code=$ROOM_CODE&invite_token=...` +3. 预览页正常展示 +4. 点「立即加入」完成入会 +5. 截图 → `02-bob-joined-from-notify.png` + +### 步骤 4:过期邀请回归 + +手工构造过期通知(通过 DB 或 gorm script): + +```sql +-- 插入一条 Bob 收到的过期邀请通知(expired_at=1 天前) +INSERT INTO notifications (user_id, notify_type, title, content, ext, expired_at, created_at) +VALUES (56, 'meeting_invitation', '会议邀请', + 'Alice 邀请你加入会议 000-000-000', + '{"room_code":"000-000-000","invite_token":"expired"}', + UNIX_TIMESTAMP() - 86400, NOW()); +``` + +1. Bob tab 刷新通知 tab +2. 期望:该卡片渲染为 **disabled 单按钮「邀请已过期」** +3. 尝试点击应无跳转 +4. 截图 → `03-notify-expired-invite.png` + +### 步骤 5:清理 + +1. SQL 删除步骤 4 插入的测试通知 +2. 结束会议(若 Alice 仍在会中) + +## 预期结果汇总 + +| 断言 | 预期 | +|---|---| +| 通知中心实时刷新 | 邀请送达后 3s 内出现新卡片 | +| 卡片双按钮 | 「立即加入」 + 「稍后」 | +| 「立即加入」跳转 | `preview?mode=join&code=xxx&invite_token=yyy` | +| 过期卡片 | 单按钮 disabled「邀请已过期」 | + +## 失败排查 + +| 现象 | 定位 | +|---|---| +| Bob 通知 tab 空 | WS `notify.new` 事件未收到 / notify store 未订阅 | +| 「立即加入」点击无反应 | `components/notify/NotifyCard.vue` 按钮 handler | +| 过期卡片仍 clickable | NotifyCard 的 `isExpired` 判定逻辑 | diff --git a/.playwright-mcp/task16/scenarios/04-host-toolkit-and-transfer.md b/.playwright-mcp/task16/scenarios/04-host-toolkit-and-transfer.md new file mode 100644 index 0000000..f4ac455 --- /dev/null +++ b/.playwright-mcp/task16/scenarios/04-host-toolkit-and-transfer.md @@ -0,0 +1,139 @@ +# 场景 04:主持人四件套 + 宽限期自动转让 + +## 目标 + +- **验证 Task 15 主持人四件套**:请他静音、请他开麦、转让主持人、踢出会议 +- **验证 Task 8 host 宽限期状态机**(P1-1 原子转让 + P1-7 处理锁): + - host 主动断开 WS → 宽限期内重连 → 撤销转让 + - host 宽限期到期 → 自动转让给最早加入的参会人 +- 覆盖设计 §6.5(生命周期状态机)、§6.6(权限矩阵) + +## 前置条件 + +依 [`../README.md`](../README.md) 通用前置;**需 3 个账号 Alice/Bob/Carol 同时入会**。 + +## MCP 执行脚本 A:主持人四件套 + +### 步骤 1:三人入会 + +1. Alice 创建即时会议 → `$ROOM_CODE` +2. Bob 用会议号加入 +3. Carol 用会议号加入 +4. 截图 → `01-three-joined.png` + +### 步骤 2:Alice 请他静音 Bob + +1. Alice tab 打开成员面板 → Bob 右侧 「⋯」 菜单 → 「请他静音」 +2. Bob tab 期望: + - Toast:「主持人已将你的麦克风关闭」 + - `localAudioEnabled=false` + - 成员面板自己的 AV 图标变灰 +3. Alice tab 期望:Bob 行 AV 图标变灰 +4. 截图 → `02-bob-muted-by-host.png` + +### 步骤 3:Alice 请他开麦 Bob + +1. Alice tab → Bob 「⋯」菜单 → 「请他开麦」 +2. Bob tab 期望: + - 弹窗请求确认「主持人请你开启麦克风,是否同意?」 + - 点「同意」后 `localAudioEnabled=true` +3. 截图 → `03-bob-ask-unmute.png` + +### 步骤 4:Alice 踢出 Carol + +1. Alice tab → Carol 「⋯」菜单 → 「踢出会议」 +2. 弹窗确认「踢出 Carol?」→ 确认 +3. Carol tab 期望: + - 收到 `meeting.member.kicked` 广播 + - Toast:「主持人已将你移出会议」 + - 跳回会议首页 +4. Alice / Bob tab 期望:Carol 行从成员列表消失 +5. 截图 → `04-carol-kicked.png` + +### 步骤 5:Alice 转让主持人给 Bob + +1. Alice tab → Bob 「⋯」菜单 → 「转让主持人」 +2. 弹窗确认 → 确认 +3. **原子性验证**(P1-1): + ```bash + mysql -e "SELECT host_id, (SELECT role FROM meeting_participants WHERE room_id=r.id AND user_id=55) AS alice_role, \ + (SELECT role FROM meeting_participants WHERE room_id=r.id AND user_id=56) AS bob_role \ + FROM meeting_rooms r WHERE room_code='$ROOM_CODE'" echochat + ``` + 期望同时满足:`host_id=56` + `alice_role=participant` + `bob_role=host`(同一事务原子更新) +4. Alice tab / Bob tab 工具栏切换(Alice 看到"结束会议"按钮消失,Bob 看到它出现) +5. 截图 → `05-host-transferred.png` + +## MCP 执行脚本 B:宽限期自动转让 + +### 前置:接续场景 A 的会议;此时 Bob 是主持人,Alice 是参会人 + +### 步骤 6:Bob 断开 WS(模拟网络断开) + +1. Bob tab DevTools → Network 面板 → Throttling 切换到 **Offline** +2. 等待 WS 心跳超时(~15s),Bob 客户端 `_onWsDisconnected` 触发 +3. 期望 Redis 立即写入 `echo:meeting:host_grace:$ROOM_CODE`: + ```bash + redis-cli GET "echo:meeting:host_grace:$ROOM_CODE" + # 期望:JSON {host_id:56, started_at:..., grace_until:...} + redis-cli TTL "echo:meeting:host_grace:$ROOM_CODE" + # 期望:约 120 秒(配置 `meeting.host_grace_seconds`) + ``` +4. Alice tab 期望:工具栏显示「主持人掉线中(宽限期 2:00)」倒计时(若 UI 已实现) + +### 步骤 7a:宽限期内重连(撤销转让) + +1. 15 秒后 Bob tab Network 恢复 Online +2. 期望 `_onWsReconnected` 触发,执行 `OnHostReconnect`: + ```bash + redis-cli EXISTS "echo:meeting:host_grace:$ROOM_CODE" + # 期望:0(key 已 DEL) + ``` +3. Alice tab 工具栏倒计时消失 +4. 查日志: + ```bash + tail -50 backend/go-service/logs/*.log | grep "host 宽限期内重连" + # 期望:`host 宽限期内重连,撤销转让计划` + ``` + +### 步骤 7b:宽限期到期(自动转让) + +**若步骤 7a 已执行,请先让 Bob 重新 Offline 并等待整个 120s 周期完成。** + +1. Bob tab Offline,保持 120+s +2. 期望本地 timer 到期触发 `HandleHostGraceExpired`: + - Alice tab 收到 `meeting.host.changed` 广播,`auto_reason='host_grace_expired'` + - 工具栏/成员面板 Alice 头像变为主持人标识 + - `meeting_rooms.host_id=55` + `meeting_participants.role` 原子变更 +3. **处理锁验证(P1-7)**: + ```bash + # 宽限期到期瞬间抓一下处理锁 + redis-cli GET "echo:meeting:host_grace_handling:$ROOM_CODE" + # 期望:`1`(SETNX 成功 60s TTL),60s 后自然过期 + ``` +4. 截图 → `06-host-auto-transferred.png` + +### 步骤 8:清理 + +Alice(现任主持人)结束会议,走场景 01 的资源清理断言。 + +## 预期结果汇总 + +| 断言 | 预期 | +|---|---| +| 请他静音 | Bob `localAudioEnabled=false` + toast | +| 请他开麦 | Bob 弹窗确认后 `localAudioEnabled=true` | +| 踢出 | Carol 跳首页 + 成员列表移除 | +| 主动转让原子性 | `host_id` + 两行 `role` 同事务更新 | +| host 宽限期 Redis | key 写入 + TTL≈120s | +| 宽限内重连 | key DEL + 本地 timer cancel | +| 宽限到期自动转让 | 最早加入者成为新 host + 广播 `host.changed` | +| 处理锁 SETNX | `host_grace_handling:*` 存在 60s | + +## 失败排查 + +| 现象 | 定位 | +|---|---| +| 转让后 `host_id` 和 `role` 不一致 | P1-1 回归:检查 `TransferHost` 是否包在单事务 | +| 宽限到期但 host 不变 | 看 handling 锁是否被"自然过期 + DEL=0"误拦(P1-7 应已修) | +| 多节点部署下双重转让 | SETNX 处理锁应保证单节点唯一处理 | diff --git a/.playwright-mcp/task16/scenarios/05-reopen-5times-resource-cleanup.md b/.playwright-mcp/task16/scenarios/05-reopen-5times-resource-cleanup.md new file mode 100644 index 0000000..75dbaf1 --- /dev/null +++ b/.playwright-mcp/task16/scenarios/05-reopen-5times-resource-cleanup.md @@ -0,0 +1,130 @@ +# 场景 05:反复开关 5 次会议 —— 资源清理专项回归 + +## 目标 + +- **端到端验证 Step A 资源清理修复(提交 `f5ae095`)**: + - 前端 `_engine` / `AudioContext` / `setInterval` / `_pendingBroadcastTimers` 在每次结束会议后完全释放 + - 后端 Redis `resource:*` / `member_state:*` / `host_grace:*` / `empty_ttl:*` 零累积 + - mediasoup Router / Transport / Producer / Consumer 不残留 +- 回归 B1 / B2 / B3 三条资源清理路径 +- 资源断言曲线:5 轮循环后 Redis `dbsize` 与 mediasoup `/internal/info` 恢复到 baseline + +## 前置条件 + +依 [`../README.md`](../README.md) 通用前置;额外确认: + +- 单浏览器 context 双 tab(Alice + Bob) +- 开启 Chromium DevTools → Memory → **勾选「Record allocation stacks」**,在脚本执行前 + 第 5 轮结束后各拍一次 heap snapshot + +## MCP 执行脚本 + +### 步骤 0:baseline 采集 + +```bash +# baseline_redis.txt +redis-cli DBSIZE | tee /tmp/baseline_redis.txt +redis-cli KEYS 'echo:meeting:*' | wc -l >> /tmp/baseline_redis.txt + +# baseline_media.json +curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info \ + | jq .stats > /tmp/baseline_media.json +``` + +在 Alice tab 的 DevTools Performance 面板录制整段脚本;脚本起止时刻各打一个 `performance.mark`。 + +### 步骤 1-5:循环 5 次创建→打通→结束 + +对 `i in {1..5}`: + +1. **创建**:Alice tab 会议首页 → 创建即时会议 → 设备预览 → 立即进入(记录 `$ROOM_CODE_i`) +2. **加入**:Bob tab 通过会议号加入 `$ROOM_CODE_i` +3. **打通断言**(轻量):两端 `browser_evaluate` 检查 `localAudioEnabled && localVideoEnabled` +4. **结束**:Alice 点「结束会议」;Bob tab 等待 `room.ended` toast +5. **结束后清理断言**(每轮都要做): + ```bash + echo "===== Round $i =====" + redis-cli KEYS "echo:meeting:resource:$ROOM_CODE_i:*" # 必须为空 + redis-cli KEYS "echo:meeting:member_state:$ROOM_CODE_i:*" # 必须为空 + redis-cli KEYS "echo:meeting:host_grace:$ROOM_CODE_i" # 必须 nil + redis-cli KEYS "echo:meeting:empty_ttl:$ROOM_CODE_i" # 必须 nil + ``` +6. **前端资源断言**(每轮): + ```js + // 在 Alice tab 的结束页执行 + const store = window.__pinia?.state?.value?.meeting + return { + round: $i, + localState: store.localState, // 'ended' + pendingBroadcastTimers: window.__broadcastTimersSize ?? 'N/A', + } + ``` +7. **回首页**:Alice tab 物理返回(`browser_press_key` Back / 点击结束页「返回」按钮)触发 `onUnload` → `exitEndedRoom` +8. `browser_evaluate` 再次确认 pinia state 全 reset:`localState='idle' && participantCount=0 && !engineExists` +9. Bob tab 同样回首页 +10. **延迟 2 秒**模拟用户操作间隙,进入下一轮 + +### 步骤 6:5 轮结束后的累积断言(核心) + +```bash +# 对比 baseline,必须零增量 +echo "===== Redis 累积对比 =====" +redis-cli DBSIZE +redis-cli KEYS 'echo:meeting:*' | wc -l +diff /tmp/baseline_redis.txt <(redis-cli DBSIZE && redis-cli KEYS 'echo:meeting:*' | wc -l) + +echo "===== media-server 累积对比 =====" +curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq .stats > /tmp/after5_media.json +diff /tmp/baseline_media.json /tmp/after5_media.json + +echo "===== MySQL 快照 =====" +mysql -e "SELECT COUNT(*) AS active FROM meeting_rooms WHERE status='active'" echochat +# 期望:active=0 +mysql -e "SELECT COUNT(*) AS alive FROM meeting_participants WHERE left_at IS NULL" echochat +# 期望:alive=0 +``` + +### 步骤 7:前端内存快照对比 + +1. 在 Alice tab 打开 DevTools → Memory → 拍 heap snapshot #2(5 轮结束后) +2. 对比 #1(baseline)与 #2: + - 关键类:`MediaStream` / `RTCPeerConnection` / `AudioContext` / `Consumer` / `Producer` 的对象数量 + - 允许少量增长(mediasoup-client `Device` 缓存),但必须远小于 5× +3. 记录 Performance tab 的「JS Heap」曲线:应呈锯齿下降(每次 GC 回收),**不得持续单调上升** + +## 预期结果汇总 + +| 断言维度 | 期望 | +|---|---| +| 每轮 Redis 清理 | `resource/member_state/host_grace/empty_ttl` 全空 | +| 5 轮累积 Redis `DBSIZE` | 与 baseline 一致(允许 ±2 容错,针对 session/heartbeat key) | +| 5 轮累积 media-server | Router / Transport / Producer / Consumer 回到 baseline | +| MySQL | `active` 会议数 = 0,`left_at IS NULL` 参与者 = 0 | +| 前端 heap snapshot | 关键对象类数量增长 ≤ 2(近似常数,非线性) | +| 前端 Performance | JS Heap 曲线呈锯齿,无持续上升趋势 | +| 控制台 | 无 `unhandled promise rejection` / `NotReadableError` / `leaked listener` | + +## 资源清理验证矩阵(对应 Step A 修复) + +| 代码路径 | 验证手段 | 通过条件 | +|---|---|---| +| F1 `_onRoomEnded` 结束页数据保留 | `localState='ended'` 时 `currentRoom` 非 null | 结束页正确渲染 | +| F1 `exitEndedRoom` 动作 | onUnload 后 `currentRoom=null` | pinia state reset | +| F2 `endMeeting` 本地兜底 | 断开 WS 再结束(额外场景) | 本地仍能触发 `_onRoomEnded` | +| F3 `_pendingBroadcastTimers` | 结束后 `clearTimeout` | `window.__broadcastTimersSize=0` | +| B1 `cleanupRoomRedisResidual` | 每轮 Redis 查 | `resource:*` / `member_state:*` = 0 | +| B3 `OnRoomEnded` timer 取消 | pprof / sync.Map 大小 | `graceTimers` / `emptyTTLTimers` 稳定无增长 | + +## 失败排查 + +| 现象 | 定位 | +|---|---| +| 某轮 `resource:*` 非空 | `EndRoom` 未走 cleanup → 检查 commit `f5ae095` 是否正确落地 | +| Redis `DBSIZE` 持续增长 | 检查 session/heartbeat key 是否泄漏;排除 meeting 模块 | +| 前端 heap 持续上升 | 抓 heap diff,找保留路径(常见:`Producer._events`、`Vue reactive` 持有 Consumer) | +| `localState` 卡 `ended` 不回 `idle` | `onUnload` 未调 `exitEndedRoom` 或 `room.vue` 组件未卸载(检查 H5 `uni.reLaunch` 行为) | + +## 延伸测试(可选) + +- **10 轮**:循环次数改 10,观察是否线性无增长 +- **跨 tab 并发**:第 3 轮时 Bob 主动 leave(而非等 Alice end),验证双路径清理一致 +- **主动断网**:第 4 轮打通后 Alice 关网卡 2 分钟 → 触发 host grace → 进入场景 04 路径