docs(meeting): Task 16 E2E 剧本落盘 + .gitignore 精细化
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
This commit is contained in:
15
.gitignore
vendored
15
.gitignore
vendored
@@ -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
|
||||
|
||||
91
.playwright-mcp/task16/README.md
Normal file
91
.playwright-mcp/task16/README.md
Normal file
@@ -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/<scene>/`。
|
||||
|
||||
## 资源断言脚本
|
||||
|
||||
每个场景结束后需要运行的资源检查(用于证明 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),由开发者手工过一遍并记录结论。
|
||||
39
.playwright-mcp/task16/manual-regression.md
Normal file
39
.playwright-mcp/task16/manual-regression.md
Normal file
@@ -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`
|
||||
- 异常描述(若 ❌):...
|
||||
- 修复动作:...
|
||||
```
|
||||
147
.playwright-mcp/task16/scenarios/01-create-and-join-by-code.md
Normal file
147
.playwright-mcp/task16/scenarios/01-create-and-join-by-code.md
Normal file
@@ -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) |
|
||||
@@ -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` 未调 / 事务回滚 |
|
||||
86
.playwright-mcp/task16/scenarios/03-notify-quickjoin.md
Normal file
86
.playwright-mcp/task16/scenarios/03-notify-quickjoin.md
Normal file
@@ -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` 判定逻辑 |
|
||||
139
.playwright-mcp/task16/scenarios/04-host-toolkit-and-transfer.md
Normal file
139
.playwright-mcp/task16/scenarios/04-host-toolkit-and-transfer.md
Normal file
@@ -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 处理锁应保证单节点唯一处理 |
|
||||
@@ -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 路径
|
||||
Reference in New Issue
Block a user