diff --git a/.claude/skills/memind-scenario-test/SKILL.md b/.claude/skills/memind-scenario-test/SKILL.md new file mode 100644 index 0000000..fcc9e58 --- /dev/null +++ b/.claude/skills/memind-scenario-test/SKILL.md @@ -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 +``` + +列出场景: + +```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/.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 | diff --git a/.claude/skills/memind-test/SKILL.md b/.claude/skills/memind-test/SKILL.md new file mode 100644 index 0000000..f81225b --- /dev/null +++ b/.claude/skills/memind-test/SKILL.md @@ -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 +``` + +## 选模式 + +| 用户意图 | 模式 | +|----------|------| +| 改完代码、日常验证 | `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 文件」替代上述入口 diff --git a/.cursor/skills/memind-scenario-test/SKILL.md b/.cursor/skills/memind-scenario-test/SKILL.md new file mode 100644 index 0000000..fcc9e58 --- /dev/null +++ b/.cursor/skills/memind-scenario-test/SKILL.md @@ -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 +``` + +列出场景: + +```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/.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 | diff --git a/.cursor/skills/memind-test/SKILL.md b/.cursor/skills/memind-test/SKILL.md new file mode 100644 index 0000000..e1448a1 --- /dev/null +++ b/.cursor/skills/memind-test/SKILL.md @@ -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 +``` + +## 选模式 + +| 用户意图 | 模式 | +|----------|------| +| 改完代码、日常验证 | `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 文件」替代上述入口 diff --git a/package.json b/package.json index a292812..c85a355 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scenarios/_template.json b/scenarios/_template.json new file mode 100644 index 0000000..535441b --- /dev/null +++ b/scenarios/_template.json @@ -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 + } + } + } + ] +} diff --git a/scenarios/suzhou-page.json b/scenarios/suzhou-page.json new file mode 100644 index 0000000..346afb9 --- /dev/null +++ b/scenarios/suzhou-page.json @@ -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 + } + } + } + ] +} diff --git a/scripts/run-memind-tests.mjs b/scripts/run-memind-tests.mjs new file mode 100644 index 0000000..28b3f7a --- /dev/null +++ b/scripts/run-memind-tests.mjs @@ -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); +} diff --git a/scripts/run-scenario-test.mjs b/scripts/run-scenario-test.mjs new file mode 100644 index 0000000..c1d28dc --- /dev/null +++ b/scripts/run-scenario-test.mjs @@ -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 ] [--list] [--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); +}); diff --git a/scripts/scenario-test-lib.mjs b/scripts/scenario-test-lib.mjs new file mode 100644 index 0000000..093728f --- /dev/null +++ b/scripts/scenario-test-lib.mjs @@ -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; +}