chore(test): 新增统一测试入口与端到端场景模拟脚本
- scripts/run-memind-tests.mjs:按改动文件自动匹配 test + verify 范围 (quick/changed/guards/release/full 模式),配套 memind-test skill - scripts/run-scenario-test.mjs + scenario-test-lib.mjs:真实登录 + 多轮 聊天 + 页面生成 + 公网可访问性的端到端模拟,配套 memind-scenario-test skill 与首个场景 scenarios/suzhou-page.json - package.json 新增 test:memind / test:scenario 脚本别名 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: memind-scenario-test
|
||||
description: >-
|
||||
Run Memind end-to-end scenario simulations (login, multi-turn chat, page
|
||||
generation, public URL checks). Use when the user asks for 模拟场景测试, 场景测试,
|
||||
E2E 流程验证, 模拟用户操作, or describes a step-by-step chat/page test flow.
|
||||
---
|
||||
|
||||
# Memind 场景模拟测试
|
||||
|
||||
用户说「**帮我模拟场景测试**」或描述多步聊天/做页面流程时,**必须**走本 skill,不要手动拼 curl 或浏览器点选。
|
||||
|
||||
## 唯一入口
|
||||
|
||||
```bash
|
||||
node scripts/run-scenario-test.mjs --scenario <id>
|
||||
```
|
||||
|
||||
列出场景:
|
||||
|
||||
```bash
|
||||
node scripts/run-scenario-test.mjs --list
|
||||
```
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 本地 Portal 已运行(默认 `http://127.0.0.1:8081`,可用 `pnpm dev`)
|
||||
2. goosed / agent 链路可用(场景会真实发消息并等待 agent 回复)
|
||||
3. 账户默认 `john` / `888888`;密码可用 `JOHN_PASSWORD` 覆盖
|
||||
|
||||
启动前检查:
|
||||
|
||||
```bash
|
||||
curl -sf http://127.0.0.1:8081/auth/status
|
||||
```
|
||||
|
||||
## 默认场景:苏州攻略页面
|
||||
|
||||
对应用户定义的流程,场景文件 [`scenarios/suzhou-page.json`](../../../scenarios/suzhou-page.json):
|
||||
|
||||
1. 登录 `john` / `888888`
|
||||
2. 发送 `hi`,等待 assistant 回复
|
||||
3. 发送 `帮我看看苏州攻略,1日攻略,做一个页面`
|
||||
4. 验证:收到回复、含苏州关键词、有公网页面链接、HTTP 200、页面内容命中关键词
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
node scripts/run-scenario-test.mjs --scenario suzhou-page
|
||||
```
|
||||
|
||||
## Agent 工作流
|
||||
|
||||
1. **确认场景**:用户未指定时默认 `suzhou-page`;说了别的流程则查 `--list` 或新建场景文件
|
||||
2. **确认服务**:Portal `/auth/status` 必须 200
|
||||
3. **执行脚本**:完整跑完,不要中途改用浏览器手点
|
||||
4. **失败处理**:读失败项 → 修代码或环境 → 重跑同一场景
|
||||
5. **汇报**:用下方模板
|
||||
|
||||
## 扩展新场景
|
||||
|
||||
复制 [`scenarios/_template.json`](../../../scenarios/_template.json) 为 `scenarios/<id>.json`。
|
||||
|
||||
步骤类型(当前支持):
|
||||
|
||||
| action | 说明 |
|
||||
|--------|------|
|
||||
| `login` | HTTP 登录,取 cookie + userId |
|
||||
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
|
||||
|
||||
`chat.expect` 可选断言:
|
||||
|
||||
- `assistantMinChars` / `timeoutMs`
|
||||
- `replyKeywords`:回复文本关键词
|
||||
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
|
||||
|
||||
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
|
||||
|
||||
## 结果模板
|
||||
|
||||
```markdown
|
||||
## 场景测试结果
|
||||
|
||||
- 场景:suzhou-page(苏州攻略一日游页面)
|
||||
- Portal:http://127.0.0.1:8081
|
||||
- 账户:john
|
||||
- 结果:通过 / 失败
|
||||
|
||||
### 步骤
|
||||
1. 登录 — 通过
|
||||
2. hi → 回复 — 通过(N 字)
|
||||
3. 苏州页面 — 通过 / 失败
|
||||
|
||||
### 页面(如有)
|
||||
- 链接:
|
||||
- HTTP:
|
||||
- 关键词:
|
||||
|
||||
### 失败项与下一步
|
||||
```
|
||||
|
||||
## 与 memind-test 的分工
|
||||
|
||||
| 诉求 | 用哪个 |
|
||||
|------|--------|
|
||||
| 模拟真实用户聊天 + 做页面 | **本 skill**(`run-scenario-test.mjs`) |
|
||||
| 改代码后跑单测/回归守卫 | `memind-test` skill(`run-memind-tests.mjs`) |
|
||||
| 103 发包 | `portal-release` skill |
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: memind-test
|
||||
description: >-
|
||||
Run Memind scoped or release tests automatically after code changes. Use when
|
||||
the user asks to test, verify, run tests, check regressions, or before
|
||||
merging/releasing; also after editing protected MindSpace/chat/agent paths.
|
||||
---
|
||||
|
||||
# Memind 自动测试
|
||||
|
||||
不要手动拼凑 `node --test` 命令。统一入口:
|
||||
|
||||
```bash
|
||||
node scripts/run-memind-tests.mjs --mode <mode>
|
||||
```
|
||||
|
||||
## 选模式
|
||||
|
||||
| 用户意图 | 模式 |
|
||||
|----------|------|
|
||||
| 改完代码、日常验证 | `changed`(默认) |
|
||||
| 快速冒烟 | `quick` |
|
||||
| 只跑回归守卫 | `guards` |
|
||||
| 发包前 | `release` |
|
||||
| 全量单测 | `full` |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
node scripts/run-memind-tests.mjs
|
||||
node scripts/run-memind-tests.mjs --mode quick
|
||||
node scripts/run-memind-tests.mjs --mode release
|
||||
```
|
||||
|
||||
对比基准分支时用 `--base origin/main`。
|
||||
|
||||
## Agent 工作流
|
||||
|
||||
1. **判断模式**:用户说「测试/验证/跑一下」→ `changed`;说「发包前」→ `release`;说「全量」→ `full`。
|
||||
2. **执行**:在仓库根目录跑脚本,不要跳过。
|
||||
3. **失败时**:读报错 → 修代码 → 用同一模式重跑,直到通过。
|
||||
4. **汇报**:用下方模板,不要只说「通过了」。
|
||||
|
||||
## 受保护路径(改后必跑 guards)
|
||||
|
||||
改这些文件时,即使 `changed` 未命中也要加跑 `guards`:
|
||||
|
||||
- `mindspace-public-finish-sync.mjs`、`chat-finish-sync.mjs`
|
||||
- `conversation-display.mjs`、`src/utils/message.ts`、`src/hooks/useTKMindChat.ts`
|
||||
- `mindspace-pages.mjs`、`mindspace-page-sync-service.mjs`、`server.mjs`
|
||||
|
||||
```bash
|
||||
node scripts/run-memind-tests.mjs --mode guards
|
||||
```
|
||||
|
||||
详见 [docs/regression-guards/README.md](../../../docs/regression-guards/README.md)。
|
||||
|
||||
## 结果模板
|
||||
|
||||
```markdown
|
||||
## 测试结果
|
||||
|
||||
- 模式:`changed` / `quick` / `guards` / `release` / `full`
|
||||
- 变更文件:N 个(列出关键路径)
|
||||
- 执行:X 个 test + Y 个 verify
|
||||
- 结果:通过 / 失败
|
||||
|
||||
### 失败项(如有)
|
||||
- 命令:
|
||||
- 错误摘要:
|
||||
- 下一步:
|
||||
```
|
||||
|
||||
## 与发布 skill 的关系
|
||||
|
||||
- 日常开发:本 skill + `changed` / `guards`
|
||||
- 103 生产发布:用 `portal-release` skill,其发包前测试与本 skill 的 `release` 模式一致
|
||||
- 不要混用「手动挑几个 test 文件」替代上述入口
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: memind-scenario-test
|
||||
description: >-
|
||||
Run Memind end-to-end scenario simulations (login, multi-turn chat, page
|
||||
generation, public URL checks). Use when the user asks for 模拟场景测试, 场景测试,
|
||||
E2E 流程验证, 模拟用户操作, or describes a step-by-step chat/page test flow.
|
||||
---
|
||||
|
||||
# Memind 场景模拟测试
|
||||
|
||||
用户说「**帮我模拟场景测试**」或描述多步聊天/做页面流程时,**必须**走本 skill,不要手动拼 curl 或浏览器点选。
|
||||
|
||||
## 唯一入口
|
||||
|
||||
```bash
|
||||
node scripts/run-scenario-test.mjs --scenario <id>
|
||||
```
|
||||
|
||||
列出场景:
|
||||
|
||||
```bash
|
||||
node scripts/run-scenario-test.mjs --list
|
||||
```
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 本地 Portal 已运行(默认 `http://127.0.0.1:8081`,可用 `pnpm dev`)
|
||||
2. goosed / agent 链路可用(场景会真实发消息并等待 agent 回复)
|
||||
3. 账户默认 `john` / `888888`;密码可用 `JOHN_PASSWORD` 覆盖
|
||||
|
||||
启动前检查:
|
||||
|
||||
```bash
|
||||
curl -sf http://127.0.0.1:8081/auth/status
|
||||
```
|
||||
|
||||
## 默认场景:苏州攻略页面
|
||||
|
||||
对应用户定义的流程,场景文件 [`scenarios/suzhou-page.json`](../../../scenarios/suzhou-page.json):
|
||||
|
||||
1. 登录 `john` / `888888`
|
||||
2. 发送 `hi`,等待 assistant 回复
|
||||
3. 发送 `帮我看看苏州攻略,1日攻略,做一个页面`
|
||||
4. 验证:收到回复、含苏州关键词、有公网页面链接、HTTP 200、页面内容命中关键词
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
node scripts/run-scenario-test.mjs --scenario suzhou-page
|
||||
```
|
||||
|
||||
## Agent 工作流
|
||||
|
||||
1. **确认场景**:用户未指定时默认 `suzhou-page`;说了别的流程则查 `--list` 或新建场景文件
|
||||
2. **确认服务**:Portal `/auth/status` 必须 200
|
||||
3. **执行脚本**:完整跑完,不要中途改用浏览器手点
|
||||
4. **失败处理**:读失败项 → 修代码或环境 → 重跑同一场景
|
||||
5. **汇报**:用下方模板
|
||||
|
||||
## 扩展新场景
|
||||
|
||||
复制 [`scenarios/_template.json`](../../../scenarios/_template.json) 为 `scenarios/<id>.json`。
|
||||
|
||||
步骤类型(当前支持):
|
||||
|
||||
| action | 说明 |
|
||||
|--------|------|
|
||||
| `login` | HTTP 登录,取 cookie + userId |
|
||||
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
|
||||
|
||||
`chat.expect` 可选断言:
|
||||
|
||||
- `assistantMinChars` / `timeoutMs`
|
||||
- `replyKeywords`:回复文本关键词
|
||||
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
|
||||
|
||||
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
|
||||
|
||||
## 结果模板
|
||||
|
||||
```markdown
|
||||
## 场景测试结果
|
||||
|
||||
- 场景:suzhou-page(苏州攻略一日游页面)
|
||||
- Portal:http://127.0.0.1:8081
|
||||
- 账户:john
|
||||
- 结果:通过 / 失败
|
||||
|
||||
### 步骤
|
||||
1. 登录 — 通过
|
||||
2. hi → 回复 — 通过(N 字)
|
||||
3. 苏州页面 — 通过 / 失败
|
||||
|
||||
### 页面(如有)
|
||||
- 链接:
|
||||
- HTTP:
|
||||
- 关键词:
|
||||
|
||||
### 失败项与下一步
|
||||
```
|
||||
|
||||
## 与 memind-test 的分工
|
||||
|
||||
| 诉求 | 用哪个 |
|
||||
|------|--------|
|
||||
| 模拟真实用户聊天 + 做页面 | **本 skill**(`run-scenario-test.mjs`) |
|
||||
| 改代码后跑单测/回归守卫 | `memind-test` skill(`run-memind-tests.mjs`) |
|
||||
| 103 发包 | `portal-release` skill |
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: memind-test
|
||||
description: >-
|
||||
Run Memind scoped or release tests automatically after code changes. Use when
|
||||
the user asks to test, verify, run tests, check regressions, or before
|
||||
merging/releasing; also after editing protected MindSpace/chat/agent paths.
|
||||
---
|
||||
|
||||
# Memind 自动测试
|
||||
|
||||
不要手动拼凑 `node --test` 命令。统一入口:
|
||||
|
||||
```bash
|
||||
node scripts/run-memind-tests.mjs --mode <mode>
|
||||
```
|
||||
|
||||
## 选模式
|
||||
|
||||
| 用户意图 | 模式 |
|
||||
|----------|------|
|
||||
| 改完代码、日常验证 | `changed`(默认) |
|
||||
| 快速冒烟 | `quick` |
|
||||
| 只跑回归守卫 | `guards` |
|
||||
| 发包前 | `release` |
|
||||
| 全量单测 | `full` |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
node scripts/run-memind-tests.mjs
|
||||
node scripts/run-memind-tests.mjs --mode quick
|
||||
node scripts/run-memind-tests.mjs --mode release
|
||||
```
|
||||
|
||||
对比基准分支时用 `--base origin/main`。
|
||||
|
||||
## Agent 工作流
|
||||
|
||||
1. **判断模式**:用户说「测试/验证/跑一下」→ `changed`;说「发包前」→ `release`;说「全量」→ `full`。
|
||||
2. **执行**:在仓库根目录跑脚本,不要跳过。
|
||||
3. **失败时**:读报错 → 修代码 → 用同一模式重跑,直到通过。
|
||||
4. **汇报**:用下方模板,不要只说「通过了」。
|
||||
|
||||
## 受保护路径(改后必跑 guards)
|
||||
|
||||
改这些文件时,即使 `changed` 未命中也要加跑 `guards`:
|
||||
|
||||
- `mindspace-public-finish-sync.mjs`、`chat-finish-sync.mjs`
|
||||
- `conversation-display.mjs`、`src/utils/message.ts`、`src/hooks/useTKMindChat.ts`
|
||||
- `mindspace-pages.mjs`、`mindspace-page-sync-service.mjs`、`server.mjs`
|
||||
|
||||
```bash
|
||||
node scripts/run-memind-tests.mjs --mode guards
|
||||
```
|
||||
|
||||
详见 [docs/regression-guards/README.md](../../../docs/regression-guards/README.md)。
|
||||
|
||||
## 结果模板
|
||||
|
||||
```markdown
|
||||
## 测试结果
|
||||
|
||||
- 模式:`changed` / `quick` / `guards` / `release` / `full`
|
||||
- 变更文件:N 个(列出关键路径)
|
||||
- 执行:X 个 test + Y 个 verify
|
||||
- 结果:通过 / 失败
|
||||
|
||||
### 失败项(如有)
|
||||
- 命令:
|
||||
- 错误摘要:
|
||||
- 下一步:
|
||||
```
|
||||
|
||||
## 与发布 skill 的关系
|
||||
|
||||
- 日常开发:本 skill + `changed` / `guards`
|
||||
- **模拟用户聊天/做页面**:用 `memind-scenario-test` skill(`run-scenario-test.mjs`)
|
||||
- 103 生产发布:用 `portal-release` skill,其发包前测试与本 skill 的 `release` 模式一致
|
||||
- 不要混用「手动挑几个 test 文件」替代上述入口
|
||||
@@ -53,6 +53,8 @@
|
||||
"smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs",
|
||||
"smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs",
|
||||
"mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs",
|
||||
"test:memind": "node scripts/run-memind-tests.mjs",
|
||||
"test:scenario": "node scripts/run-scenario-test.mjs",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
|
||||
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"id": "your-scenario-id",
|
||||
"name": "场景名称",
|
||||
"description": "一句话说明这个场景测什么",
|
||||
"account": {
|
||||
"username": "john",
|
||||
"password": "888888"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"action": "login",
|
||||
"label": "登录"
|
||||
},
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "第一步对话",
|
||||
"message": "hi",
|
||||
"expect": {
|
||||
"assistantMinChars": 1,
|
||||
"timeoutMs": 120000
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "第二步对话(可带页面断言)",
|
||||
"message": "你的测试消息",
|
||||
"expect": {
|
||||
"assistantMinChars": 20,
|
||||
"timeoutMs": 300000,
|
||||
"replyKeywords": ["关键词"],
|
||||
"page": {
|
||||
"keywords": ["页面里应出现的词"],
|
||||
"requirePublicLink": true,
|
||||
"requireHttp200": true,
|
||||
"requireMindspaceCover": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "suzhou-page",
|
||||
"name": "苏州攻略一日游页面",
|
||||
"description": "登录 john → 打招呼 → 请求生成苏州 1 日攻略页面 → 验证回复与公网可访问",
|
||||
"account": {
|
||||
"username": "john",
|
||||
"password": "888888"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"action": "login",
|
||||
"label": "登录 john 账户"
|
||||
},
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "发送 hi 并等待回复",
|
||||
"message": "hi",
|
||||
"expect": {
|
||||
"assistantMinChars": 1,
|
||||
"timeoutMs": 120000
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "请求苏州攻略页面并验证",
|
||||
"message": "帮我看看苏州攻略,1日攻略,做一个页面",
|
||||
"expect": {
|
||||
"assistantMinChars": 20,
|
||||
"timeoutMs": 300000,
|
||||
"replyKeywords": ["苏州"],
|
||||
"page": {
|
||||
"keywords": ["苏州"],
|
||||
"requirePublicLink": true,
|
||||
"requireHttp200": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Memind scoped test runner.
|
||||
* Usage: node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
|
||||
const MODES = new Set(['quick', 'changed', 'guards', 'release', 'full']);
|
||||
|
||||
const QUICK_TESTS = [
|
||||
'capabilities.test.mjs',
|
||||
'llm-providers.test.mjs',
|
||||
'user-auth.test.mjs',
|
||||
'wechat-mp.test.mjs',
|
||||
];
|
||||
|
||||
const GUARD_SCRIPTS = [
|
||||
'verify:mindspace-publish-guards',
|
||||
'verify:mindspace-page-sync-guards',
|
||||
];
|
||||
|
||||
const RELEASE_TESTS = [
|
||||
...QUICK_TESTS,
|
||||
'wechat-oauth.test.mjs',
|
||||
'wechat-pay.test.mjs',
|
||||
'mindspace-public-finish-sync.test.mjs',
|
||||
'chat-finish-sync.test.mjs',
|
||||
'conversation-display.test.mjs',
|
||||
'mindspace-pages.test.mjs',
|
||||
'mindspace-page-sync-service.test.mjs',
|
||||
];
|
||||
|
||||
const RELEASE_CHECKS = [
|
||||
'check:mindspace-public-links',
|
||||
'verify:mindspace-publish-guards:full',
|
||||
];
|
||||
|
||||
const SCOPE_RULES = [
|
||||
{
|
||||
id: 'publish-chat-finish',
|
||||
patterns: [
|
||||
/mindspace-public-finish/i,
|
||||
/chat-finish-sync/i,
|
||||
/conversation-display/i,
|
||||
/useTKMindChat/i,
|
||||
/message\.ts$/,
|
||||
/mindspace-h5-html-finish/i,
|
||||
],
|
||||
tests: [
|
||||
'mindspace-public-finish-sync.test.mjs',
|
||||
'mindspace-h5-html-finish-guard.test.mjs',
|
||||
'chat-finish-sync.test.mjs',
|
||||
'conversation-display.test.mjs',
|
||||
],
|
||||
verify: ['verify:mindspace-publish-guards'],
|
||||
},
|
||||
{
|
||||
id: 'page-sync-thumbnail',
|
||||
patterns: [
|
||||
/mindspace-page-sync/i,
|
||||
/mindspace-pages/i,
|
||||
/public-site-bases/i,
|
||||
/publicUrl\.ts$/,
|
||||
/mindspace-thumbnails/i,
|
||||
/mindspace-workspace-thumbnails/i,
|
||||
],
|
||||
tests: [
|
||||
'mindspace-pages.test.mjs',
|
||||
'mindspace-page-sync-service.test.mjs',
|
||||
'public-site-bases.test.mjs',
|
||||
'mindspace-thumbnails.test.mjs',
|
||||
'mindspace-workspace-thumbnails.test.mjs',
|
||||
],
|
||||
verify: ['verify:mindspace-page-sync-guards'],
|
||||
},
|
||||
{
|
||||
id: 'wechat',
|
||||
patterns: [/wechat/i],
|
||||
tests: [
|
||||
'wechat-mp.test.mjs',
|
||||
'wechat-oauth.test.mjs',
|
||||
'wechat-pay.test.mjs',
|
||||
'user-auth.test.mjs',
|
||||
],
|
||||
verify: ['verify:wechat-channel-isolation'],
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
patterns: [/billing/i, /sse-billing/i],
|
||||
tests: [
|
||||
'billing.test.mjs',
|
||||
'billing-recharge.test.mjs',
|
||||
'billing-subscription.test.mjs',
|
||||
'sse-billing.test.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agent-run',
|
||||
patterns: [/agent-run/i, /tool-gateway/i, /goosed-proxy/i],
|
||||
tests: [
|
||||
'agent-run-gateway.test.mjs',
|
||||
'agent-run-routes.test.mjs',
|
||||
'agent-run-stream.test.mjs',
|
||||
'tool-gateway.test.mjs',
|
||||
'goosed-proxy-boundary.test.mjs',
|
||||
'chat-agent-run-gate.test.mjs',
|
||||
],
|
||||
verify: ['verify:goosed-proxy-boundary'],
|
||||
},
|
||||
{
|
||||
id: 'memory-v2',
|
||||
patterns: [/memory-v2/i, /scripts\/check-memory-v2/i, /scripts\/smoke-memory-v2/i],
|
||||
tests: [
|
||||
'memory-v2.test.mjs',
|
||||
'memory-v2-runtime.test.mjs',
|
||||
'memory-v2-health.test.mjs',
|
||||
'memory-v2-backend-contract.test.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plaza',
|
||||
patterns: [/plaza/i],
|
||||
tests: [
|
||||
'plaza-posts.test.mjs',
|
||||
'plaza-interactions.test.mjs',
|
||||
'plaza-algorithm.test.mjs',
|
||||
'plaza-seo.test.mjs',
|
||||
'plaza-ops.test.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mindspace-core',
|
||||
patterns: [/mindspace/i, /server\.mjs$/, /src\/hooks\/useTKMind/i],
|
||||
tests: [
|
||||
'mindspace.test.mjs',
|
||||
'mindspace-public-route.test.mjs',
|
||||
'mindspace-public-links.test.mjs',
|
||||
'mindspace-runtime-config.test.mjs',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
|
||||
|
||||
Modes:
|
||||
changed Run tests matched to git diff (default)
|
||||
quick Fast smoke for routine checks
|
||||
guards Regression guard scripts only
|
||||
release Pre-portal-release checklist
|
||||
full Entire npm test suite`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let mode = 'changed';
|
||||
let base = 'HEAD';
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--mode') {
|
||||
mode = argv[++i] ?? '';
|
||||
} else if (arg === '--base') {
|
||||
base = argv[++i] ?? 'HEAD';
|
||||
} else if (arg === '-h' || arg === '--help') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!MODES.has(mode)) {
|
||||
throw new Error(`Invalid mode: ${mode}`);
|
||||
}
|
||||
return { mode, base };
|
||||
}
|
||||
|
||||
function runStep(label, fn) {
|
||||
console.log(`\n==> ${label}`);
|
||||
const ok = fn();
|
||||
if (!ok) {
|
||||
throw new Error(`${label} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeTest(files) {
|
||||
const existing = files.filter((file) => fs.existsSync(path.join(root, file)));
|
||||
if (existing.length === 0) {
|
||||
console.log('(skip: no matching test files on disk)');
|
||||
return true;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['--test', ...existing], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function runNpmScript(script) {
|
||||
const result = spawnSync('npm', ['run', script], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function gitChangedFiles(base) {
|
||||
const result = spawnSync('git', ['diff', '--name-only', `${base}...HEAD`], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const fallback = spawnSync('git', ['diff', '--name-only', base], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (fallback.status !== 0) {
|
||||
return [];
|
||||
}
|
||||
return fallback.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function gitStagedAndUnstaged() {
|
||||
const result = spawnSync('git', ['status', '--porcelain'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return [];
|
||||
}
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => line.replace(/^. [^ ]+ -> /, '').replace(/^.. /, ''));
|
||||
}
|
||||
|
||||
function directTestForSource(file) {
|
||||
const base = path.basename(file).replace(/\.(mjs|ts|tsx|js)$/, '');
|
||||
const candidates = [
|
||||
`${base}.test.mjs`,
|
||||
path.join('scripts', `${base}.test.mjs`),
|
||||
path.join('mindspace-service', `${base}.test.mjs`),
|
||||
];
|
||||
return candidates.filter((candidate) => fs.existsSync(path.join(root, candidate)));
|
||||
}
|
||||
|
||||
function resolveChangedScope(files) {
|
||||
const tests = new Set(QUICK_TESTS);
|
||||
const verify = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
for (const test of directTestForSource(file)) {
|
||||
tests.add(test);
|
||||
}
|
||||
for (const rule of SCOPE_RULES) {
|
||||
if (rule.patterns.some((pattern) => pattern.test(file))) {
|
||||
for (const test of rule.tests) {
|
||||
tests.add(test);
|
||||
}
|
||||
for (const script of rule.verify ?? []) {
|
||||
verify.add(script);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
tests: [...tests],
|
||||
verify: [...verify],
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(scope) {
|
||||
console.log('\n--- planned scope ---');
|
||||
if (scope.files?.length) {
|
||||
console.log(`changed files (${scope.files.length}):`);
|
||||
for (const file of scope.files) {
|
||||
console.log(` - ${file}`);
|
||||
}
|
||||
}
|
||||
console.log(`tests (${scope.tests.length}):`);
|
||||
for (const test of scope.tests) {
|
||||
console.log(` - ${test}`);
|
||||
}
|
||||
if (scope.verify.length) {
|
||||
console.log(`verify scripts (${scope.verify.length}):`);
|
||||
for (const script of scope.verify) {
|
||||
console.log(` - npm run ${script}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { mode, base } = parseArgs(process.argv);
|
||||
let scope;
|
||||
|
||||
switch (mode) {
|
||||
case 'quick':
|
||||
scope = { tests: QUICK_TESTS, verify: [] };
|
||||
break;
|
||||
case 'guards':
|
||||
scope = { tests: [], verify: GUARD_SCRIPTS };
|
||||
break;
|
||||
case 'release':
|
||||
scope = { tests: RELEASE_TESTS, verify: RELEASE_CHECKS };
|
||||
break;
|
||||
case 'full':
|
||||
runStep('full test suite (npm test)', () => runNpmScript('test'));
|
||||
console.log('\nmemind tests ok (full)');
|
||||
return;
|
||||
case 'changed':
|
||||
default: {
|
||||
const changed = [...new Set([...gitChangedFiles(base), ...gitStagedAndUnstaged()])];
|
||||
if (changed.length === 0) {
|
||||
console.log('No changed files detected; falling back to quick smoke.');
|
||||
scope = { tests: QUICK_TESTS, verify: [], files: [] };
|
||||
} else {
|
||||
scope = resolveChangedScope(changed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
summarize(scope);
|
||||
|
||||
for (const test of scope.tests) {
|
||||
runStep(`node --test ${test}`, () => runNodeTest([test]));
|
||||
}
|
||||
|
||||
for (const script of scope.verify) {
|
||||
runStep(`npm run ${script}`, () => runNpmScript(script));
|
||||
}
|
||||
|
||||
console.log(`\nmemind tests ok (${mode})`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`\n${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Memind 场景模拟测试入口。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/run-scenario-test.mjs --scenario suzhou-page
|
||||
* node scripts/run-scenario-test.mjs --list
|
||||
* JOHN_PASSWORD=888888 node scripts/run-scenario-test.mjs
|
||||
*/
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import {
|
||||
createAgentRun,
|
||||
createReporter,
|
||||
listScenarios,
|
||||
loadScenario,
|
||||
loginViaApi,
|
||||
resolvePortalBase,
|
||||
snapshotPublicHtml,
|
||||
verifyPageAccess,
|
||||
waitForAssistantGrowth,
|
||||
waitForRunTerminal,
|
||||
extractAssistantTexts,
|
||||
getSession,
|
||||
} from './scenario-test-lib.mjs';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
node scripts/run-scenario-test.mjs [--scenario <id>] [--list] [--port <port>]
|
||||
|
||||
Examples:
|
||||
node scripts/run-scenario-test.mjs --scenario suzhou-page
|
||||
node scripts/run-scenario-test.mjs --list`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let scenarioId = 'suzhou-page';
|
||||
let listOnly = false;
|
||||
let port = Number(process.env.H5_PORT ?? 8081);
|
||||
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--scenario' && argv[i + 1]) {
|
||||
scenarioId = argv[++i];
|
||||
} else if (arg === '--port' && argv[i + 1]) {
|
||||
port = Number(argv[++i]);
|
||||
} else if (arg === '--list') {
|
||||
listOnly = true;
|
||||
} else if (arg === '-h' || arg === '--help') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { scenarioId, listOnly, port };
|
||||
}
|
||||
|
||||
async function ensurePortalReady(baseUrl) {
|
||||
const response = await fetch(`${baseUrl}/auth/status`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenario(scenario, port) {
|
||||
const reporter = createReporter();
|
||||
const baseUrl = resolvePortalBase(port);
|
||||
const account = {
|
||||
username: scenario.account?.username ?? 'john',
|
||||
password:
|
||||
process.env.JOHN_PASSWORD
|
||||
?? process.env.H5_ACCESS_PASSWORD
|
||||
?? scenario.account?.password
|
||||
?? '888888',
|
||||
};
|
||||
|
||||
console.log(`==> 场景: ${scenario.name ?? scenario.id}`);
|
||||
console.log(` Portal: ${baseUrl}`);
|
||||
console.log(` 账户: ${account.username}\n`);
|
||||
|
||||
await ensurePortalReady(baseUrl);
|
||||
|
||||
let auth = null;
|
||||
let sessionId = null;
|
||||
let assistantCount = 0;
|
||||
let assistantCombinedLength = 0;
|
||||
let publishKey = null;
|
||||
let htmlBefore = [];
|
||||
|
||||
for (const step of scenario.steps ?? []) {
|
||||
const label = step.label ?? step.action;
|
||||
console.log(`\n--- ${label} ---`);
|
||||
|
||||
if (step.action === 'login') {
|
||||
auth = await loginViaApi(baseUrl, account, reporter);
|
||||
publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.action === 'chat') {
|
||||
if (!auth) {
|
||||
throw new Error('chat 步骤前必须先 login');
|
||||
}
|
||||
|
||||
if (step.expect?.page) {
|
||||
htmlBefore = await snapshotPublicHtml(publishKey);
|
||||
}
|
||||
|
||||
const run = await createAgentRun(baseUrl, auth.cookie, {
|
||||
message: step.message,
|
||||
sessionId,
|
||||
});
|
||||
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
|
||||
|
||||
const terminal = await waitForRunTerminal(
|
||||
baseUrl,
|
||||
auth.cookie,
|
||||
run.runId,
|
||||
step.expect?.timeoutMs ?? 300_000,
|
||||
);
|
||||
sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? sessionId;
|
||||
|
||||
if (terminal.status === 'failed') {
|
||||
reporter.fail('run 终态', terminal.error ?? 'failed');
|
||||
continue;
|
||||
}
|
||||
reporter.pass('run 终态', terminal.status);
|
||||
|
||||
if (!sessionId) {
|
||||
reporter.fail('session', 'run 未回填 sessionId');
|
||||
continue;
|
||||
}
|
||||
|
||||
const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
|
||||
previousCount: assistantCount,
|
||||
previousCombinedLength: assistantCombinedLength,
|
||||
minChars: step.expect?.assistantMinChars ?? 1,
|
||||
timeoutMs: step.expect?.timeoutMs ?? 120_000,
|
||||
});
|
||||
|
||||
if (!reply) {
|
||||
reporter.fail('assistant 回复', '超时未收到新回复');
|
||||
continue;
|
||||
}
|
||||
|
||||
assistantCount = reply.count;
|
||||
assistantCombinedLength = reply.combined.length;
|
||||
reporter.pass('assistant 回复', `${reply.combined.length} 字 / ${reply.elapsedMs}ms`);
|
||||
console.log('\n--- 回复摘要 ---');
|
||||
console.log(reply.combined.slice(0, 600));
|
||||
|
||||
const keywords = step.expect?.replyKeywords ?? [];
|
||||
if (keywords.length) {
|
||||
const hit = keywords.filter((word) => reply.combined.includes(word));
|
||||
if (hit.length === 0) {
|
||||
reporter.fail('回复关键词', `未命中: ${keywords.join(', ')}`);
|
||||
} else {
|
||||
reporter.pass('回复关键词', hit.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (step.expect?.page) {
|
||||
await verifyPageAccess({
|
||||
baseUrl,
|
||||
cookie: auth.cookie,
|
||||
publishKey,
|
||||
replyText: reply.combined,
|
||||
htmlBefore,
|
||||
expect: step.expect.page,
|
||||
reporter,
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
reporter.fail('未知步骤', step.action ?? 'missing action');
|
||||
}
|
||||
|
||||
return reporter.summary();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { scenarioId, listOnly, port } = parseArgs(process.argv);
|
||||
|
||||
if (listOnly) {
|
||||
const scenarios = await listScenarios();
|
||||
console.log('可用场景:');
|
||||
for (const item of scenarios) {
|
||||
console.log(`- ${item.id}: ${item.name}${item.description ? ` — ${item.description}` : ''}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scenario = await loadScenario(scenarioId);
|
||||
const code = await runScenario(scenario, port);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { USER_COOKIE } from '../user-auth.mjs';
|
||||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function createReporter() {
|
||||
const checks = [];
|
||||
const issues = [];
|
||||
|
||||
return {
|
||||
checks,
|
||||
issues,
|
||||
pass(label, detail = '') {
|
||||
checks.push({ ok: true, label, detail });
|
||||
console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`);
|
||||
},
|
||||
fail(label, detail = '') {
|
||||
issues.push({ label, detail });
|
||||
checks.push({ ok: false, label, detail });
|
||||
console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`);
|
||||
},
|
||||
summary() {
|
||||
console.log('\n=== 汇总 ===');
|
||||
console.log(`通过: ${checks.filter((item) => item.ok).length}/${checks.length}`);
|
||||
if (issues.length) {
|
||||
console.log('失败项:');
|
||||
for (const item of issues) {
|
||||
console.log(` - ${item.label}: ${item.detail}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
console.log('场景测试全部通过');
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePortalBase(port) {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export async function loginViaApi(baseUrl, { username, password }, reporter) {
|
||||
const response = await fetch(`${baseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !body?.authenticated) {
|
||||
throw new Error(`登录失败 ${response.status}: ${JSON.stringify(body)}`);
|
||||
}
|
||||
|
||||
const setCookie = response.headers.getSetCookie?.() ?? [];
|
||||
const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`))
|
||||
?? response.headers.get('set-cookie');
|
||||
const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`));
|
||||
const token = match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
if (!token) {
|
||||
throw new Error('登录成功但未解析到会话 Cookie');
|
||||
}
|
||||
|
||||
reporter.pass('登录', `${username} (${body.user?.id ?? 'unknown-id'})`);
|
||||
return {
|
||||
token,
|
||||
cookie: `${USER_COOKIE}=${token}`,
|
||||
user: body.user ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserMessage(text) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
metadata: { userVisible: true, displayText: text },
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null }) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const body = {
|
||||
request_id: requestId,
|
||||
user_message: buildUserMessage(message),
|
||||
};
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/agent/runs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: cookie,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`POST /api/agent/runs ${response.status}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
const run = payload.run ?? payload;
|
||||
return {
|
||||
runId: run.id,
|
||||
requestId,
|
||||
sessionId: run.sessionId ?? run.agent_session_id ?? sessionId ?? null,
|
||||
status: run.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentRun(baseUrl, cookie, runId) {
|
||||
const response = await fetch(`${baseUrl}/api/agent/runs/${runId}`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET /api/agent/runs/${runId} ${response.status}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload.run ?? payload;
|
||||
}
|
||||
|
||||
export async function getSession(baseUrl, cookie, sessionId) {
|
||||
const response = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status, payload };
|
||||
}
|
||||
return { ok: true, session: payload };
|
||||
}
|
||||
|
||||
export function extractAssistantTexts(sessionPayload) {
|
||||
const conversation = sessionPayload?.conversation
|
||||
?? sessionPayload?.session?.conversation
|
||||
?? sessionPayload?.messages
|
||||
?? [];
|
||||
const texts = [];
|
||||
for (const message of conversation) {
|
||||
if (message?.role !== 'assistant') continue;
|
||||
const content = message?.content;
|
||||
if (typeof content === 'string') texts.push(content);
|
||||
else if (Array.isArray(content)) {
|
||||
texts.push(
|
||||
content
|
||||
.map((item) => (item?.type === 'text' ? item.text ?? '' : ''))
|
||||
.join(''),
|
||||
);
|
||||
}
|
||||
}
|
||||
return texts.filter(Boolean);
|
||||
}
|
||||
|
||||
export function extractPublicLinks(text, baseUrl) {
|
||||
const links = new Set();
|
||||
const markdown = /\[[^\]]+\]\((https?:\/\/[^)]+|\/MindSpace\/[^)]+)\)/gi;
|
||||
const bare = /(https?:\/\/[^\s)]+?\/MindSpace\/[^\s)]+?\.html)/gi;
|
||||
const relative = /(\/MindSpace\/[^\s)]+?\.html)/gi;
|
||||
for (const pattern of [markdown, bare, relative]) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const raw = match[1] ?? match[0];
|
||||
if (!raw) continue;
|
||||
links.add(raw.startsWith('http') ? raw : new URL(raw, baseUrl).href);
|
||||
}
|
||||
}
|
||||
return [...links];
|
||||
}
|
||||
|
||||
async function listPublicHtmlFiles(publishDir) {
|
||||
const publicDir = path.join(publishDir, 'public');
|
||||
try {
|
||||
const entries = await fs.readdir(publicDir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.html')) continue;
|
||||
const fullPath = path.join(publicDir, entry.name);
|
||||
const stat = await fs.stat(fullPath);
|
||||
files.push({
|
||||
name: entry.name,
|
||||
fullPath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
relativePublicPath: `public/${entry.name}`,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function snapshotPublicHtml(publishKey) {
|
||||
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
|
||||
return listPublicHtmlFiles(publishDir);
|
||||
}
|
||||
|
||||
export async function waitForRunTerminal(baseUrl, cookie, runId, timeoutMs) {
|
||||
const started = Date.now();
|
||||
let last = null;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
last = await getAgentRun(baseUrl, cookie, runId);
|
||||
if (['succeeded', 'failed'].includes(last.status)) {
|
||||
return last;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
throw new Error(`run ${runId} 超时未完成,最后状态 ${last?.status ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, {
|
||||
previousCount = 0,
|
||||
previousCombinedLength = 0,
|
||||
minChars = 1,
|
||||
timeoutMs = 120000,
|
||||
}) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const result = await getSession(baseUrl, cookie, sessionId);
|
||||
if (result.ok) {
|
||||
const texts = extractAssistantTexts(result.session);
|
||||
const combined = texts.join('\n').trim();
|
||||
const grew = texts.length > previousCount || combined.length > previousCombinedLength;
|
||||
if (grew && combined.length >= minChars) {
|
||||
return {
|
||||
texts,
|
||||
combined,
|
||||
elapsedMs: Date.now() - started,
|
||||
count: texts.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function verifyPageAccess({
|
||||
baseUrl,
|
||||
cookie,
|
||||
publishKey,
|
||||
replyText,
|
||||
htmlBefore = [],
|
||||
expect = {},
|
||||
reporter,
|
||||
}) {
|
||||
const keywords = expect.keywords ?? [];
|
||||
const links = extractPublicLinks(replyText, baseUrl);
|
||||
let pageUrl = links.find((url) => /\/MindSpace\/.+\.html/i.test(url)) ?? null;
|
||||
|
||||
if (!pageUrl && publishKey) {
|
||||
const htmlAfter = await listPublicHtmlFiles(path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey));
|
||||
const beforeSet = new Set(htmlBefore.map((item) => item.fullPath));
|
||||
const fresh = htmlAfter
|
||||
.filter((item) => !beforeSet.has(item.fullPath))
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
if (fresh[0]) {
|
||||
pageUrl = new URL(
|
||||
`/MindSpace/${publishKey}/${fresh[0].relativePublicPath}`,
|
||||
baseUrl,
|
||||
).href;
|
||||
reporter.pass('页面落盘', fresh[0].relativePublicPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (expect.requirePublicLink !== false) {
|
||||
if (!pageUrl) {
|
||||
reporter.fail('公网页面链接', '回复中未找到 MindSpace 链接,public/ 也无新 HTML');
|
||||
return false;
|
||||
}
|
||||
reporter.pass('公网页面链接', pageUrl);
|
||||
}
|
||||
|
||||
if (pageUrl && expect.requireHttp200 !== false) {
|
||||
const response = await fetch(pageUrl, { headers: cookie ? { Cookie: cookie } : {} });
|
||||
const html = await response.text();
|
||||
if (response.status !== 200) {
|
||||
reporter.fail('页面 HTTP 状态', `${response.status} ${pageUrl}`);
|
||||
return false;
|
||||
}
|
||||
reporter.pass('页面 HTTP 200', pageUrl);
|
||||
|
||||
if (keywords.length) {
|
||||
const hit = keywords.filter((word) => html.includes(word));
|
||||
if (hit.length === 0) {
|
||||
reporter.fail('页面内容关键词', `未命中: ${keywords.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
reporter.pass('页面内容关键词', hit.join(', '));
|
||||
}
|
||||
|
||||
if (expect.requireMindspaceCover && !/mindspace-cover/i.test(html)) {
|
||||
reporter.fail('页面元数据', '缺少 mindspace-cover');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadScenario(scenarioId) {
|
||||
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
|
||||
const raw = await fs.readFile(scenarioPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
export async function listScenarios() {
|
||||
const dir = path.join(repoRoot, 'scenarios');
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const scenarios = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('_')) continue;
|
||||
const scenario = JSON.parse(await fs.readFile(path.join(dir, entry.name), 'utf8'));
|
||||
scenarios.push({
|
||||
id: scenario.id ?? entry.name.replace(/\.json$/, ''),
|
||||
name: scenario.name ?? scenario.id,
|
||||
description: scenario.description ?? '',
|
||||
});
|
||||
}
|
||||
return scenarios;
|
||||
}
|
||||
Reference in New Issue
Block a user