chore: land tree view-state, vault, Pi module split, and repo hygiene
Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
This commit is contained in:
@@ -1,5 +1,63 @@
|
||||
# mnote 测试参考
|
||||
|
||||
## ⚡ 浏览器测试快速启动(AI agent 专用)
|
||||
|
||||
> **执行浏览器验证前,先确认此节,避免浪费时间。**
|
||||
|
||||
### 1. 检查服务是否已运行
|
||||
```bash
|
||||
ss -ltnp 'sport = :3000'
|
||||
# 有输出 = 服务已运行,跳到步骤 3
|
||||
# 无输出 = 服务未运行,继续步骤 2
|
||||
```
|
||||
|
||||
### 2. 启动服务(后台模式)
|
||||
```bash
|
||||
nohup npm run dev:hot > /tmp/mnote-dev.log 2>&1 &
|
||||
echo $!
|
||||
# 等待 5-10 秒后检查日志
|
||||
sleep 8 && grep -E 'gateway 已启动|Running' /tmp/mnote-dev.log
|
||||
```
|
||||
**关键点:**
|
||||
- 启动命令是 `npm run dev:hot`(不是 desktop:hot)
|
||||
- `dev:hot` 默认启用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1`
|
||||
- 使用 cargo-watch 自动重编译
|
||||
- 端口固定为 3000
|
||||
|
||||
### 3. 浏览器验证步骤(Paseo)
|
||||
```bash
|
||||
# 1. 新建标签页
|
||||
paseo_browser_new_tab url=http://localhost:3000/auth
|
||||
|
||||
# 2. 等待页面加载
|
||||
paseo_browser_wait url=localhost:3000 timeoutMs=15000
|
||||
|
||||
# 3. 截图(如果返回 "tab not painted",等 3 秒重试)
|
||||
paseo_browser_screenshot
|
||||
|
||||
# 4. 快速登录(snapshot 找到 @e5 即"测试账号快速登录"按钮)
|
||||
paseo_browser_click ref=@e5
|
||||
|
||||
# 5. 等待进入工作区
|
||||
paseo_browser_wait url=localhost:3000 text=工作区
|
||||
|
||||
# 6. 截图
|
||||
paseo_browser_screenshot
|
||||
```
|
||||
|
||||
### 4. 测试账号
|
||||
- 邮箱:`mnote.e2e@example.com`
|
||||
- 密码:`MnoteE2E123!`
|
||||
- 用户名:`mnote-e2e`
|
||||
|
||||
### 5. 常见问题
|
||||
| 问题 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| ERR_CONNECTION_REFUSED | 服务器未启动 | 先运行 `npm run dev:hot` |
|
||||
| tab not painted | 页面未渲染完成 | 等待 3 秒后重试截图 |
|
||||
| Paseo 终端无输出 | 前台进程阻塞 | 用 `nohup ... &` 后台启动 |
|
||||
| `desktop:hot` vs `dev:hot` | 启动命令不同 | 浏览器测试用 `dev:hot` |
|
||||
|
||||
这份文档面向 `/mnt/Data1T/mnote/scripts` 目录下的现有测试脚本,目标不是重新设计测试体系,而是把当前已经在用的 smoke、回归脚本、截图取证和人工复核方式整理成一套可执行参考。
|
||||
|
||||
当前结论先说在前面:
|
||||
@@ -36,12 +94,12 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- 入口与认证:`task114-rust-web-gateway-entry-smoke.js`、`task159-auth-entry-smoke.js`、`task097-homepage-entry-smoke.js`
|
||||
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
|
||||
- leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
|
||||
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`。`task452` 只覆盖普通本地搜索、settings API、tag/backlink API 和旧 local-index UI 不复活;资料库问答、PDF/Office/image source ingestion 与引用回跳改跑 WeKnora / provider-neutral knowledge-rag 脚本。
|
||||
- LightRAG / Knowledge RAG 资料库:`task781-knowledge-bases-registry-smoke.js`、`task785-weknora-create-kb-folder-ingest-search-smoke.js`、`task787-weknora-default-lightrag-legacy-smoke.js` 覆盖默认 provider、知识库列表、摄取、搜索、引用桥接与 legacy LightRAG guard。旧 `task529/530/531/534` LightRAG 脚本只作为 legacy fallback / 历史对照,不进入默认回归。旧 OpenHub 专项资料库 smoke 已软归档到 `recycle/20260712-pi-ts-openhub-retirement/`;旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 WeKnora、LightRAG legacy、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。
|
||||
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`。`task452` 只覆盖普通本地搜索、settings API、tag/backlink API 和旧 local-index UI 不复活;资料库问答、PDF/Office/image source ingestion 与引用回跳改跑 LightRAG / provider-neutral knowledge-rag 脚本。
|
||||
- LightRAG / Knowledge RAG 资料库:LightRAG 是默认 provider。资料源、摄取、source scope 与 watcher 改动优先跑 `task532-knowledge-rag-docx-ingestion-smoke.js`、`task533-knowledge-rag-source-watcher-sync-smoke.js`、`task534-knowledge-rag-source-management-scope-smoke.js`、`task538-knowledge-rag-source-scope-api-smoke.js`;引用和 Page AI final answer 改动补跑 `task529-knowledge-rag-citation-resource-tab-smoke.js`、`task530-knowledge-rag-page-ai-final-answer-smoke.js`;真实 Pi + LightRAG 服务可用时补跑 `task-pi-lab-real-lightrag-smoke.js`。WeKnora 仅是历史/备用 provider,不进入默认回归。旧 OpenHub 专项资料库 smoke 已软归档到 `recycle/20260712-pi-ts-openhub-retirement/`;旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 LightRAG、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。
|
||||
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
|
||||
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
|
||||
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
|
||||
- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;opencode WebUI embed 改动补跑 `task763-page-ai-opencode-embed-smoke.js`,只验证真实登录、Page AI 抽屉、opencode iframe/host chrome 和 changed file/open/refresh DOM hook,不 mock 聊天成功;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`
|
||||
- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`。`task763` / `task765` 是 OpenCode debug fallback 专项脚本,不属于默认回归,待 debug route 彻底移除时一并退役。
|
||||
- Dev hot / OnlyOffice live bridge:Sidebar dev hot reload 入口改动补跑 `task514-sidebar-dev-hot-reload-gating-smoke.js`;OnlyOffice live bridge session / scope / HTTP 工具边界改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`;bridge session/token/queue/current/close 和 `docKey/pageOrigin` 元数据改动补跑 `task516-onlyoffice-bridge-multisession-browser-smoke.js`;bridge plugin index / `Asc.plugin` direct loop / plugin 元数据透传改动补跑 `task517-onlyoffice-bridge-plugin-direct-smoke.js`;真实 ONLYOFFICE iframe / DocumentServer session、同文档双 tab、resource scope 和非 dry-run 写入落点改动补跑 `task518-onlyoffice-real-iframe-session-scope-smoke.js`;Page AI 真实 UI 选择 Office target 并冻结 live bridge session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`
|
||||
- local resource lifecycle API:`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。
|
||||
|
||||
@@ -52,6 +110,8 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- 旧 Convex 兼容 smoke 已软删除到 `recycle/20260527-legacy-cleanup/convex/scripts/`,包括 `task163`、`task174`、`task175`、`task427`、`task428`、`task429`、`task430`、`task431`、`task432`、`task433`、`task434`。
|
||||
- `task179-tree-create-delete-no-reload-smoke.js`:覆盖 `/tree` debug shell。`/tree` 已是显式 debug/internal 边界,脚本默认阻断;只有设置 `MNOTE_ALLOW_DEBUG_TREE_SMOKE=1` 时才可执行。
|
||||
- `task123-rust-web-tree-live-stream-consumer-smoke.js`:只证明 `/api/tree/events` SSE fallback 仍可用。当前 realtime 主链是 WebSocket push + SSE fallback;验证主链时优先使用 446 / 447 / 448,只有排查 fallback 时再跑 123。
|
||||
- WeKnora 默认 provider 脚本(`task544`、`task769`、`task772`、`task777`、`task781`、`task783`、`task784`、`task785`、`task787`、`task788`、`task789`、`task790`、`task79x`)已退役;它们与 LightRAG 默认 provider 主线相反。
|
||||
- Hermes/ACP/Reasonix 页面 AI 主流程 smoke 已退役;只保留 `task-hermes-page-ai-retirement-guard.js` 防止旧 runtime 复活。`task762-page-ai-board-first-smoke.js` 也已退役。
|
||||
- 旧 `task019`、`task021`、`task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
|
||||
|
||||
### 0.3 其他 current 候选脚本
|
||||
@@ -120,9 +180,8 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
代表脚本:
|
||||
|
||||
- `task119-rust-web-wolai-visual-regression-smoke.js`
|
||||
- `task129-wolai-aline-baseline-smoke.js`
|
||||
- `task130` 到 `task137` 一系列 `wolai-aline-*`
|
||||
- `task160-wolai-page-settings-shell-smoke.js`
|
||||
- 已跟踪的对标基线:`task118-rust-web-wolai-ui-parity-smoke.js`、`task119-rust-web-wolai-visual-regression-smoke.js`
|
||||
- `task128`、`task129` 到 `task137`、`task160`、`task162` 是 gitignored 的本地历史取证脚本,不属于默认回归。
|
||||
- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 Pi Lab / opencode / Page AI smoke 与 `task-page-block-ai-tools-smoke.js`。Hermes 相关 smoke 只作为 legacy guard / 历史对照。
|
||||
|
||||
这一层最重要的原则不是“文案命中”,而是:
|
||||
@@ -191,9 +250,9 @@ node scripts/task097-homepage-entry-smoke.js
|
||||
- tree / sidebar 改动:`task112`、`task120`、`task122`
|
||||
- document shell / island:`task110`、`task121`
|
||||
- local-first:优先使用 `task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task455-local-folder-mindmap-clean-smoke.js`
|
||||
- 页面设置:`task160`、`task164-page-options-visible-effect-smoke.js`
|
||||
- 页面设置:`task164-page-options-visible-effect-smoke.js`
|
||||
- 双栏:`task165`
|
||||
- AI:优先使用 Pi Lab / opencode / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;Hermes 页面 AI smoke 只作为历史对照。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
|
||||
- AI:优先使用 Pi Lab / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;其余 Hermes 页面 AI 主流程 smoke 已退役。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
|
||||
|
||||
原则:
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Multi-agent CLI for the shared AI password book (unique strategy).
|
||||
*
|
||||
* Same backend as Pi tools / HTTP:
|
||||
* list | get | resolve
|
||||
*
|
||||
* Auth: cookie session to mnote-web (default http://127.0.0.1:3000).
|
||||
* export MNOTE_BASE_URL=http://127.0.0.1:3000
|
||||
* export MNOTE_COOKIE='mnote_session=...' # or paste from browser
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/mnote-vault-cli.js list
|
||||
* node scripts/mnote-vault-cli.js get --id cred_xxx
|
||||
* node scripts/mnote-vault-cli.js resolve --id cred_xxx --field password
|
||||
*
|
||||
* Never print secrets to logs other than resolve stdout value line when --raw.
|
||||
* Default resolve prints JSON including value for tool piping; use --raw for plain value only.
|
||||
*/
|
||||
|
||||
const BASE = (process.env.MNOTE_BASE_URL || process.env.MNOTE_URL || 'http://127.0.0.1:3000').replace(
|
||||
/\/$/,
|
||||
''
|
||||
);
|
||||
const COOKIE = process.env.MNOTE_COOKIE || process.env.MNOTE_SESSION_COOKIE || '';
|
||||
|
||||
function usage() {
|
||||
console.error(`Usage:
|
||||
mnote-vault-cli.js auth-e2e # print export MNOTE_COOKIE=... for local mnote-web
|
||||
mnote-vault-cli.js list [--status active|deleted]
|
||||
mnote-vault-cli.js get --id <credentialId>
|
||||
mnote-vault-cli.js resolve --id <credentialId> --field password|apikey|token [--raw]
|
||||
mnote-vault-cli.js login --id <credentialId> [--force]
|
||||
mnote-vault-cli.js session --id <credentialId> --cookie <CookieHeader> [--source human_bridge]
|
||||
|
||||
Env: MNOTE_BASE_URL, MNOTE_COOKIE
|
||||
auth-e2e uses MNOTE_E2E_EMAIL / MNOTE_E2E_PASSWORD (defaults: mnote.e2e@example.com / MnoteE2E123!)
|
||||
|
||||
Steady-state: auth-e2e once → list (optional) → login (reuse session).
|
||||
Human Cloudflare: browser then "session" write-back.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (
|
||||
a === '--id' ||
|
||||
a === '--field' ||
|
||||
a === '--status' ||
|
||||
a === '--cookie' ||
|
||||
a === '--source' ||
|
||||
a === '--expires'
|
||||
) {
|
||||
out[a.slice(2)] = argv[++i];
|
||||
} else if (a === '--raw' || a === '--force') {
|
||||
out[a.slice(2)] = true;
|
||||
} else if (a.startsWith('--')) {
|
||||
console.error('Unknown flag', a);
|
||||
usage();
|
||||
} else {
|
||||
out._.push(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (COOKIE) headers.Cookie = COOKIE;
|
||||
if (body != null) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok || (data && data.ok === false)) {
|
||||
const msg =
|
||||
(data && (data.message || (data.error && data.error.message))) ||
|
||||
`HTTP ${res.status}`;
|
||||
const code = (data && data.code) || res.status;
|
||||
const err = new Error(String(msg));
|
||||
err.code = code;
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
return data && data.result != null ? data.result : data;
|
||||
}
|
||||
|
||||
async function authE2e() {
|
||||
const email =
|
||||
process.env.MNOTE_E2E_EMAIL || 'mnote.e2e@example.com';
|
||||
const password = process.env.MNOTE_E2E_PASSWORD || 'MnoteE2E123!';
|
||||
const body = {
|
||||
action: 'auth:signIn',
|
||||
args: {
|
||||
provider: 'password',
|
||||
params: {
|
||||
password,
|
||||
flow: 'signIn',
|
||||
account: email,
|
||||
email,
|
||||
name: email.includes('@') ? email.split('@')[0] : email,
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = await fetch(BASE + '/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const setCookies = res.headers.getSetCookie
|
||||
? res.headers.getSetCookie()
|
||||
: [];
|
||||
// Node <18 fallback: raw get may only return one
|
||||
const raw = res.headers.raw ? res.headers.raw()['set-cookie'] || [] : setCookies;
|
||||
const parts = [];
|
||||
for (const sc of raw) {
|
||||
const first = String(sc).split(';')[0].trim();
|
||||
if (first) parts.push(first);
|
||||
}
|
||||
if (!parts.length) {
|
||||
const text = await res.text();
|
||||
throw new Error(
|
||||
`auth-e2e failed HTTP ${res.status}: no Set-Cookie (${text.slice(0, 120)})`
|
||||
);
|
||||
}
|
||||
const cookieHeader = parts.join('; ');
|
||||
// Print only export line for eval/source
|
||||
console.log(`export MNOTE_COOKIE=${JSON.stringify(cookieHeader)}`);
|
||||
console.error('[mnote-vault-cli] auth-e2e ok; eval the export line (do not paste cookie into chat)');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cmd = args._[0];
|
||||
if (!cmd) usage();
|
||||
|
||||
if (cmd === 'auth-e2e') {
|
||||
await authE2e();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!COOKIE) {
|
||||
console.error(
|
||||
'Warning: MNOTE_COOKIE empty — run: eval $(node scripts/mnote-vault-cli.js auth-e2e)'
|
||||
);
|
||||
}
|
||||
|
||||
if (cmd === 'list') {
|
||||
const status = args.status || 'active';
|
||||
const q = new URLSearchParams({ status });
|
||||
const result = await api('GET', `/api/vault/ai/list?${q}`);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (cmd === 'get') {
|
||||
if (!args.id) usage();
|
||||
const result = await api('GET', `/api/vault/ai/items/${encodeURIComponent(args.id)}`);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (cmd === 'resolve') {
|
||||
if (!args.id || !args.field) usage();
|
||||
const result = await api(
|
||||
'POST',
|
||||
`/api/vault/ai/items/${encodeURIComponent(args.id)}/resolve`,
|
||||
{ field: args.field }
|
||||
);
|
||||
if (args.raw) {
|
||||
if (result && result.resolved && result.value != null) {
|
||||
process.stdout.write(String(result.value));
|
||||
if (!String(result.value).endsWith('\n')) process.stdout.write('\n');
|
||||
} else {
|
||||
console.error(
|
||||
result && result.transcriptHint
|
||||
? result.transcriptHint
|
||||
: 'not resolved'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (cmd === 'login') {
|
||||
if (!args.id) usage();
|
||||
const result = await api(
|
||||
'POST',
|
||||
`/api/vault/ai/items/${encodeURIComponent(args.id)}/login`,
|
||||
{ forceRefresh: !!args.force }
|
||||
);
|
||||
// Redact cookie in default print? Agent needs cookie — print full JSON; warn stderr.
|
||||
if (result && result.cookieHeader) {
|
||||
console.error('[mnote-vault-cli] cookieHeader present (do not paste into chat)');
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (result && result.ok === false && result.code === 'vault_login_human_required') {
|
||||
process.exit(3);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'session') {
|
||||
if (!args.id || !args.cookie) usage();
|
||||
const result = await api(
|
||||
'POST',
|
||||
`/api/vault/ai/items/${encodeURIComponent(args.id)}/session`,
|
||||
{
|
||||
cookieHeader: args.cookie,
|
||||
expiresAt: args.expires || undefined,
|
||||
source: args.source || 'human_bridge',
|
||||
}
|
||||
);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
usage();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message || String(err));
|
||||
if (err.code) console.error('code:', err.code);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -678,7 +678,9 @@ async function main() {
|
||||
result.pass = result.failures.length === 0;
|
||||
} catch (err) {
|
||||
result.steps.push({ step: "exception", status: "fail", message: String(err) });
|
||||
await screenshot("99-error").catch(() => {});
|
||||
await page
|
||||
.screenshot({ path: path.join(SCREENSHOT_DIR, "99-error.png"), fullPage: false })
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
// Start mnote-web (mock Pi) on a free port and run S2–S7 acceptance smokes.
|
||||
// Does not touch unrelated workspace state. Prefer existing BASE if set.
|
||||
|
||||
"use strict";
|
||||
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const REPO = path.resolve(__dirname, "..");
|
||||
const RUST_DIR = path.join(REPO, "rust");
|
||||
const BINARY = path.join(RUST_DIR, "target/debug/mnote-web");
|
||||
const TIMEOUT_MS = Number(process.env.MNOTE_S2_S7_TIMEOUT_MS || 120_000);
|
||||
const EXTERNAL_BASE = (process.env.MNOTE_PI_LAB_BASE || process.env.MNOTE_S2_BASE || "").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
|
||||
const SMOKES = [
|
||||
"task-s2-skills-mcp-delete-no-ghost-smoke.js",
|
||||
"task-s3-directory-grants-allowed-roots-smoke.js",
|
||||
"task-s4-pi-abort-idempotent-smoke.js",
|
||||
"task-s5-rag-health-summary-filetree-smoke.js",
|
||||
"task-s6-pi-resume-history-hydrate-smoke.js",
|
||||
"task-s7-agent-target-dirty-guard-smoke.js",
|
||||
];
|
||||
|
||||
function findFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("无法分配测试端口")));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForGateway(baseUrl) {
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/gateway/health`, {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.ok && response.headers.get("x-mnote-web-owner") === "mnote-web") {
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`gateway health: ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
throw lastError || new Error("gateway health 未就绪");
|
||||
}
|
||||
|
||||
function startGateway(port, controlPlaneDb) {
|
||||
const env = {
|
||||
...process.env,
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
|
||||
MNOTE_TURSO_LOCAL_PATH: controlPlaneDb,
|
||||
MNOTE_PAGE_AI_PI_LAB: "1",
|
||||
MNOTE_PAGE_AI_PI_LAB_RUNTIME: "mock",
|
||||
MNOTE_PAGE_AI_PI_WARMUP: "0",
|
||||
MNOTE_PAGE_AI_PI_WARMUP_SEND: "0",
|
||||
RUST_LOG: process.env.RUST_LOG || "warn",
|
||||
};
|
||||
const cmd = fs.existsSync(BINARY)
|
||||
? [BINARY]
|
||||
: ["cargo", "run", "-q", "-p", "mnote-web", "--bin", "mnote-web"];
|
||||
const child = spawn(cmd[0], cmd.slice(1), {
|
||||
cwd: RUST_DIR,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 20_000) stderr = stderr.slice(-20_000);
|
||||
});
|
||||
child.stdout.on("data", () => {});
|
||||
child._s2s7Stderr = () => stderr;
|
||||
return child;
|
||||
}
|
||||
|
||||
function runSmoke(script, base) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = {
|
||||
...process.env,
|
||||
MNOTE_PI_LAB_BASE: base,
|
||||
MNOTE_S2_BASE: base,
|
||||
MNOTE_S3_BASE: base,
|
||||
MNOTE_S4_BASE: base,
|
||||
MNOTE_S5_BASE: base,
|
||||
MNOTE_S6_BASE: base,
|
||||
MNOTE_S7_BASE: base,
|
||||
};
|
||||
const child = spawn(process.execPath, [path.join(__dirname, script)], {
|
||||
cwd: REPO,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (c) => {
|
||||
out += c.toString();
|
||||
process.stdout.write(c);
|
||||
});
|
||||
child.stderr.on("data", (c) => {
|
||||
err += c.toString();
|
||||
process.stderr.write(c);
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve({ script, out });
|
||||
else reject(new Error(`${script} exited ${code}\n${err || out}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let child = null;
|
||||
let base = EXTERNAL_BASE;
|
||||
const tmpDbDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s2s7-cp-"));
|
||||
const controlPlaneDb = path.join(tmpDbDir, "control-plane-s2s7.db");
|
||||
|
||||
try {
|
||||
if (!base) {
|
||||
const port = await findFreePort();
|
||||
base = `http://127.0.0.1:${port}`;
|
||||
console.log(`\n🚀 starting mnote-web mock on ${base}\n`);
|
||||
child = startGateway(port, controlPlaneDb);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code && code !== 0) {
|
||||
console.error(`mnote-web exited code=${code} signal=${signal}`);
|
||||
console.error(child._s2s7Stderr && child._s2s7Stderr());
|
||||
}
|
||||
});
|
||||
await waitForGateway(base);
|
||||
console.log(" gateway ready\n");
|
||||
} else {
|
||||
console.log(`\n🔗 using external BASE ${base}\n`);
|
||||
await waitForGateway(base);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const script of SMOKES) {
|
||||
console.log(`── ${script} ──`);
|
||||
await runSmoke(script, base);
|
||||
results.push(script);
|
||||
}
|
||||
console.log(`\n✅ S2–S7 all passed (${results.length}):\n ${results.join("\n ")}\n`);
|
||||
} finally {
|
||||
if (child && !child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S2–S7 runner failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -390,9 +390,9 @@ check(
|
||||
|
||||
check(
|
||||
"admin settings requires admin authorization",
|
||||
aiSettingsRs.includes("ensure_admin(&context)?") &&
|
||||
aiSettingsRs.includes("is_local_access_policy_admin_context"),
|
||||
"GET/PUT 都复用现有管理员鉴权"
|
||||
aiSettingsRs.includes("ensure_admin(&state, &context)?") &&
|
||||
aiSettingsRs.includes("current_actor_is_local_admin(state, context)"),
|
||||
"GET/PUT 都复用 session 优先的管理员鉴权"
|
||||
);
|
||||
|
||||
check(
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-agent-skill-${suffix}`;
|
||||
const runId = `run_agent_skill_${suffix}`;
|
||||
const createdIds = [];
|
||||
const sessionBodies = [];
|
||||
const runBodies = [];
|
||||
const activeProfileBodies = [];
|
||||
const memorySaveBodies = [];
|
||||
const skillToggleBodies = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/hermes/client/profiles/active", async (route) => {
|
||||
activeProfileBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, profile: { name: "chemist", active: true } }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/profiles", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
profiles: [
|
||||
{ name: "default", active: true, model: "gpt-5", gateway: "openai", alias: "Default" },
|
||||
{ name: "chemist", active: false, model: "gpt-5", gateway: "openai", alias: "Chemist" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/profile-memory**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
memorySaveBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
memory: "长期记忆",
|
||||
user: "用户偏好",
|
||||
soul: "原始人格",
|
||||
memory_mtime: Date.now(),
|
||||
user_mtime: Date.now(),
|
||||
soul_mtime: Date.now(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
categories: [
|
||||
{
|
||||
name: "writing",
|
||||
description: "Writing helpers",
|
||||
skills: [
|
||||
{
|
||||
name: "ai-writing-detection",
|
||||
description: "检测 AI 写作痕迹",
|
||||
enabled: false,
|
||||
source: "local",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
archived: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
|
||||
skillToggleBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
sessionBodies.push(body);
|
||||
const profile = body.profile || "default";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: `mnote_${profile}_${suffix}`,
|
||||
title: "当前页问答",
|
||||
profile,
|
||||
traceId: `trace_session_${suffix}`,
|
||||
persistence: "hermes_on_first_run",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
session: {
|
||||
sessionId: `mnote_chemist_${suffix}`,
|
||||
profile: "chemist",
|
||||
messages: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
runBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, runId, sessionId: `mnote_chemist_${suffix}` }),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: `mnote_chemist_${suffix}`, delta: "Agent " })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: `mnote_chemist_${suffix}`, output: "Agent response" })}\n\n`,
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-profile-select]").selectOption("chemist", { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "chemist", null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await page.locator('[data-page-ai-memory-editor="soul"]').fill(`SOUL-${suffix}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-memory-save="soul"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-page-ai-memory-saved") === "soul", null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-skill-search]").fill("writing", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-skill-toggle="ai-writing-detection"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "ai-writing-detection",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`请使用 chemist agent 总结 ${title}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Agent response"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
assert(activeProfileBodies.some((body) => body.name === "chemist"), "选择 agent/profile 必须调用 Hermes active profile API");
|
||||
assert(memorySaveBodies.some((body) => body.section === "soul" && body.content === `SOUL-${suffix}`), "SOUL.md 保存必须调用 Hermes memory API");
|
||||
assert(
|
||||
skillToggleBodies.some((body) => body.name === "ai-writing-detection" && body.enabled === true),
|
||||
"skill 启用必须调用 Hermes skills toggle API",
|
||||
);
|
||||
assert(runBodies.some((body) => body.profile === "chemist"), "Hermes run 必须携带当前 agent/profile");
|
||||
assert(sessionBodies.some((body) => body.profile === "chemist"), "切换 agent 后新 Hermes session 必须携带 profile");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
profile: "chemist",
|
||||
memorySectionsSaved: memorySaveBodies.map((body) => body.section),
|
||||
skillsToggled: skillToggleBodies.map((body) => body.name),
|
||||
runProfiles: runBodies.map((body) => body.profile),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function callArtifact(request, target, suffix, toolName, args) {
|
||||
return requestJson(request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
toolName,
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `mnote_smoke_artifact_${suffix}`,
|
||||
runId: `run_smoke_artifact_${suffix}`,
|
||||
toolCallId: `call_${toolName.replace(/\W+/g, "_")}_${suffix}`,
|
||||
traceId: `trace_smoke_artifact_${suffix}`,
|
||||
idempotencyKey: `idem_${toolName.replace(/\W+/g, "_")}_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["artifact.write"],
|
||||
args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function assertArtifactReferenceEdge(edgesPayload, sourceDocumentId, artifactDocumentId, artifactType) {
|
||||
const edges = Array.isArray(edgesPayload?.result?.edges) ? edgesPayload.result.edges : [];
|
||||
const edge = edges.find(
|
||||
(candidate) =>
|
||||
candidate &&
|
||||
candidate.fromNodeId === sourceDocumentId &&
|
||||
candidate.toNodeId === artifactDocumentId &&
|
||||
candidate.metadata?.kind === "ai_artifact_reference",
|
||||
);
|
||||
assert(edge, `未查询到 ${artifactDocumentId} 的 ai_artifact_reference edge`);
|
||||
assert.equal(edge.edgeType, "source_of", `${artifactDocumentId} reference edge 类型不对`);
|
||||
assert.equal(edge.metadata.artifactType, artifactType, `${artifactDocumentId} artifactType 不对`);
|
||||
assert.equal(edge.metadata.projectionOnlyGroup, "AI Artifacts", `${artifactDocumentId} projection group 不对`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-artifact-${suffix}`;
|
||||
const quickSessionId = `mnote_smoke_artifact_quick_${suffix}`;
|
||||
let quickRunIndex = 0;
|
||||
const quickRunBodies = [];
|
||||
const createdIds = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.route("**/api/hermes/tools/mnote/call", async (route) => {
|
||||
throw new Error(`页面 AI artifact 快捷入口不应直连 mnote tool route: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: quickSessionId,
|
||||
title: "当前页问答",
|
||||
traceId: `trace_quick_session_${suffix}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/sessions/${quickSessionId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: quickSessionId,
|
||||
session: { sessionId: quickSessionId, messages: [] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
quickRunIndex += 1;
|
||||
quickRunBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: quickSessionId,
|
||||
runId: `run_quick_artifact_${suffix}_${quickRunIndex}`,
|
||||
traceId: `trace_quick_artifact_${suffix}_${quickRunIndex}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/events/**", async (route) => {
|
||||
const runId = route.request().url().split("/").pop();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: quickSessionId, delta: "artifact intent queued" })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: quickSessionId, output: "artifact intent queued" })}\n\n`,
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const summary = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", {
|
||||
summary: `摘要 ${suffix}`,
|
||||
});
|
||||
const summaryRetry = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", {
|
||||
summary: `摘要 ${suffix}`,
|
||||
});
|
||||
const aiNote = await callArtifact(context.request, target, suffix, "mnote.artifact.create_ai_note", {
|
||||
content: `AI Note ${suffix}`,
|
||||
});
|
||||
const aiNoteSecond = await callArtifact(context.request, target, `${suffix}_second`, "mnote.artifact.create_ai_note", {
|
||||
content: `AI Note second ${suffix}`,
|
||||
});
|
||||
const artifactDocumentIds = Array.from(
|
||||
new Set([
|
||||
summary.result.artifactDocumentId,
|
||||
aiNote.result.artifactDocumentId,
|
||||
aiNoteSecond.result.artifactDocumentId,
|
||||
]),
|
||||
);
|
||||
createdIds.push(...artifactDocumentIds);
|
||||
|
||||
for (const result of [summary, aiNote, aiNoteSecond]) {
|
||||
assert.equal(result.ok, true, `${result.toolName} 应成功`);
|
||||
assert.equal(result.audit.effect, "write", `${result.toolName} audit 应为 write`);
|
||||
assert.equal(result.result.commandName, "tree.node.create", `${result.toolName} 必须走 tree.node.create`);
|
||||
assert.equal(result.audit.commandId, result.result.commandId, `${result.toolName} audit commandId 必须指向 Rust commandId`);
|
||||
assert(result.result.artifactDocumentId, `${result.toolName} 缺少 artifactDocumentId`);
|
||||
assert.equal(result.result.referenceEdge?.from, target.documentId, `${result.toolName} reference edge from 不对`);
|
||||
assert.equal(result.result.referenceEdge?.to, result.result.artifactDocumentId, `${result.toolName} reference edge to 不对`);
|
||||
assert.equal(result.result.referenceEdge?.kind, "ai_artifact_reference", `${result.toolName} reference edge kind 不对`);
|
||||
assert(result.result.artifacts?.commandLog, `${result.toolName} 缺少 commandLog artifact`);
|
||||
assert(result.result.artifacts?.domainEvent, `${result.toolName} 缺少 domainEvent artifact`);
|
||||
assert.equal(
|
||||
result.result.artifacts.domainEvent.eventType,
|
||||
"tree.node.created",
|
||||
`${result.toolName} domain event 类型不对`,
|
||||
);
|
||||
}
|
||||
assert.equal(summaryRetry.result.commandId, summary.result.commandId, "summary 同 idempotencyKey 重试必须返回同一 commandId");
|
||||
assert.equal(
|
||||
summaryRetry.result.artifactDocumentId,
|
||||
summary.result.artifactDocumentId,
|
||||
"summary 同页面重试必须指向同一 summary document",
|
||||
);
|
||||
assert.notEqual(
|
||||
aiNoteSecond.result.artifactDocumentId,
|
||||
aiNote.result.artifactDocumentId,
|
||||
"ai_note 多次创建必须生成独立 artifact document",
|
||||
);
|
||||
|
||||
const edgePayload = await requestJson(
|
||||
context.request,
|
||||
`/api/kernel/edges?workspaceId=${encodeURIComponent(target.workspaceId)}&nodeId=${encodeURIComponent(target.documentId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assertArtifactReferenceEdge(edgePayload, target.documentId, summary.result.artifactDocumentId, "summary");
|
||||
assertArtifactReferenceEdge(edgePayload, target.documentId, aiNote.result.artifactDocumentId, "ai_note");
|
||||
assertArtifactReferenceEdge(edgePayload, target.documentId, aiNoteSecond.result.artifactDocumentId, "ai_note");
|
||||
|
||||
const fileProjection = await requestJson(
|
||||
context.request,
|
||||
`/api/tree/projections/file?workspaceId=${encodeURIComponent(target.workspaceId)}&rootNodeId=${encodeURIComponent(target.documentId)}&depth=2`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
const projectionItems = Array.isArray(fileProjection?.result?.items) ? fileProjection.result.items : [];
|
||||
const aiArtifactsMeta = fileProjection?.result?.meta?.aiArtifacts || {};
|
||||
assert.equal(aiArtifactsMeta.title, "AI Artifacts", "AI Artifacts projection meta 标题不对");
|
||||
assert.equal(aiArtifactsMeta.projectionOnly, true, "AI Artifacts 必须是 projection-only 分组");
|
||||
assert.equal(aiArtifactsMeta.kernelNodeId, null, "AI Artifacts 不应是真实 kernel node");
|
||||
assert(
|
||||
!projectionItems.some((item) => item && item.nodeId === "AI Artifacts"),
|
||||
"AI Artifacts 不应出现在 projection items 中作为真实 node",
|
||||
);
|
||||
for (const artifactDocumentId of artifactDocumentIds) {
|
||||
assert(
|
||||
aiArtifactsMeta.artifactDocumentIds?.includes(artifactDocumentId),
|
||||
`AI Artifacts projection meta 缺少 ${artifactDocumentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const auditPayload = await requestJson(
|
||||
context.request,
|
||||
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(`trace_smoke_artifact_${suffix}`)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
},
|
||||
);
|
||||
const auditEvents = Array.isArray(auditPayload.events) ? auditPayload.events : [];
|
||||
for (const result of [summary, aiNote]) {
|
||||
assert(
|
||||
auditEvents.some(
|
||||
(event) =>
|
||||
event &&
|
||||
event.phase === "completed" &&
|
||||
event.toolCallId === result.toolCallId &&
|
||||
event.audit?.commandId === result.result.commandId,
|
||||
),
|
||||
`${result.toolName} audit 未串到 Hermes tool call 和 Rust command`,
|
||||
);
|
||||
}
|
||||
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-intent="create-summary"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("artifact intent queued"),
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('[data-page-ai-intent="create-ai-note"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (quickRunBodies.length < 2 && Date.now() < deadline) {
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
assert.equal(quickRunBodies.length, 2, "两个 artifact 快捷入口都必须发送 Hermes run intent");
|
||||
assert(
|
||||
quickRunBodies[0].message.includes("mnote.artifact.create_summary"),
|
||||
"创建 Summary 快捷入口必须发送 Hermes artifact summary intent",
|
||||
);
|
||||
assert(
|
||||
quickRunBodies[1].message.includes("mnote.artifact.create_ai_note"),
|
||||
"创建 AI Note 快捷入口必须发送 Hermes artifact ai_note intent",
|
||||
);
|
||||
assert(
|
||||
quickRunBodies.every((body) => body.documentId === target.documentId && body.sessionId === quickSessionId),
|
||||
"artifact 快捷入口必须携带当前页面 documentId 与 Hermes sessionId",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
summaryArtifactType: summary.result.artifactType,
|
||||
aiNoteArtifactType: aiNote.result.artifactType,
|
||||
summaryCommand: summary.result.commandName,
|
||||
aiNoteCommand: aiNote.result.commandName,
|
||||
summaryArtifactDocumentId: summary.result.artifactDocumentId,
|
||||
aiNoteArtifactDocumentId: aiNote.result.artifactDocumentId,
|
||||
aiNoteSecondArtifactDocumentId: aiNoteSecond.result.artifactDocumentId,
|
||||
referenceEdgeCount: edgePayload.result.edges.length,
|
||||
aiArtifactsMeta,
|
||||
auditEventCount: auditEvents.length,
|
||||
quickIntentMessages: quickRunBodies.map((body) => body.message),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-audit-${suffix}`;
|
||||
const marker = `TEST-HERMES-AI-AUDIT-${suffix}`;
|
||||
const createdIds = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const readTraceId = `trace_audit_read_${suffix}`;
|
||||
const writeTraceId = `trace_audit_write_${suffix}`;
|
||||
const deniedTraceId = `trace_audit_denied_${suffix}`;
|
||||
const readToolCallId = `call_audit_read_${suffix}`;
|
||||
const writeToolCallId = `call_audit_write_${suffix}`;
|
||||
const deniedToolCallId = `call_audit_denied_${suffix}`;
|
||||
|
||||
const read = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
toolName: "mnote.page.get",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `mnote_audit_${suffix}`,
|
||||
runId: `run_audit_read_${suffix}`,
|
||||
toolCallId: readToolCallId,
|
||||
traceId: readTraceId,
|
||||
capabilityScope: ["page.read"],
|
||||
},
|
||||
});
|
||||
assert.equal(read.audit.effect, "read", "读工具 audit effect 应为 read");
|
||||
|
||||
const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
toolName: "mnote.page.save",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `mnote_audit_${suffix}`,
|
||||
runId: `run_audit_write_${suffix}`,
|
||||
toolCallId: writeToolCallId,
|
||||
traceId: writeTraceId,
|
||||
idempotencyKey: `idem_audit_write_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["page.write"],
|
||||
args: {
|
||||
mode: "replace",
|
||||
content: [
|
||||
{
|
||||
id: `audit_block_${suffix}`,
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: marker }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(write.audit.effect, "write", "写工具 audit effect 应为 write");
|
||||
assert(write.audit.commandId, "写工具 audit 必须包含 commandId");
|
||||
|
||||
const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-mnote-actor-id": "smoke-user",
|
||||
"x-mnote-workspace-id": "ws_denied",
|
||||
},
|
||||
data: JSON.stringify({
|
||||
toolName: "mnote.page.save",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `mnote_audit_${suffix}`,
|
||||
runId: `run_audit_denied_${suffix}`,
|
||||
toolCallId: deniedToolCallId,
|
||||
traceId: deniedTraceId,
|
||||
idempotencyKey: `idem_audit_denied_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["page.write"],
|
||||
args: {
|
||||
mode: "replace",
|
||||
content: [
|
||||
{
|
||||
id: `audit_denied_block_${suffix}`,
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: marker }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const deniedText = await deniedResponse.text();
|
||||
assert.equal(deniedResponse.status(), 403, "权限失败 smoke 应返回 workspace_context_conflict");
|
||||
assert(!deniedText.includes(title), "权限失败响应不应包含页面标题");
|
||||
assert(!deniedText.includes(marker), "权限失败响应不应包含正文 marker");
|
||||
|
||||
const readAudit = await requestJson(
|
||||
context.request,
|
||||
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(readTraceId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
},
|
||||
);
|
||||
const writeAudit = await requestJson(
|
||||
context.request,
|
||||
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
},
|
||||
);
|
||||
const writePersistedAudit = await requestJson(
|
||||
context.request,
|
||||
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}&persistedOnly=true`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
},
|
||||
);
|
||||
const deniedPersistedAudit = await requestJson(
|
||||
context.request,
|
||||
`/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(deniedTraceId)}&persistedOnly=true`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
},
|
||||
);
|
||||
|
||||
const readPhases = new Set((readAudit.events || []).map((event) => event.phase));
|
||||
const writePhases = new Set((writeAudit.events || []).map((event) => event.phase));
|
||||
const writePersistedPhases = new Set((writePersistedAudit.events || []).map((event) => event.phase));
|
||||
const deniedPersistedPhases = new Set((deniedPersistedAudit.events || []).map((event) => event.phase));
|
||||
assert(readPhases.has("started"), "读工具 audit 缺少 started");
|
||||
assert(readPhases.has("completed"), "读工具 audit 缺少 completed");
|
||||
assert(writePhases.has("started"), "写工具 audit 缺少 started");
|
||||
assert(writePhases.has("completed"), "写工具 audit 缺少 completed");
|
||||
assert(writePersistedPhases.has("started"), "持久化写工具 audit 缺少 started");
|
||||
assert(writePersistedPhases.has("completed"), "持久化写工具 audit 缺少 completed");
|
||||
assert(deniedPersistedPhases.has("failed"), "持久化权限失败 audit 缺少 failed");
|
||||
assert(
|
||||
(writeAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId),
|
||||
"写工具 audit 查询结果未串到 Rust commandId",
|
||||
);
|
||||
assert(
|
||||
(writePersistedAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId),
|
||||
"持久化写工具 audit 查询结果未串到 Rust commandId",
|
||||
);
|
||||
assert(!JSON.stringify(writeAudit).includes(marker), "audit 查询结果不应包含完整正文内容");
|
||||
assert(!JSON.stringify(writePersistedAudit).includes(marker), "持久化 audit 不应包含完整正文内容");
|
||||
assert(!JSON.stringify(deniedPersistedAudit).includes(marker), "权限失败持久化 audit 不应包含完整正文内容");
|
||||
assert(!JSON.stringify(deniedPersistedAudit).includes(title), "权限失败持久化 audit 不应包含页面标题");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
readTraceId,
|
||||
writeTraceId,
|
||||
deniedTraceId,
|
||||
writeCommandId: write.audit.commandId,
|
||||
readPhases: Array.from(readPhases),
|
||||
writePhases: Array.from(writePhases),
|
||||
writePersistedPhases: Array.from(writePersistedPhases),
|
||||
deniedPersistedPhases: Array.from(deniedPersistedPhases),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-baseline-${suffix}`;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const captured = [];
|
||||
const createdIds = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/ai-agent/run") || url.includes("/api/hermes/client/")) {
|
||||
captured.push({
|
||||
phase: "request",
|
||||
method: request.method(),
|
||||
url,
|
||||
postData: request.postData() || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on("response", async (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes("/api/ai-agent/run") && !url.includes("/api/hermes/client/")) {
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
try {
|
||||
body = (await response.text()).slice(0, 1600);
|
||||
} catch {
|
||||
body = "<unreadable>";
|
||||
}
|
||||
captured.push({
|
||||
phase: "response",
|
||||
status: response.status(),
|
||||
url,
|
||||
body,
|
||||
headers: response.headers(),
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill("概括当前页面标题和第一段", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').length > 0,
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const aiRequests = captured.filter((entry) => entry.phase === "request");
|
||||
assert(aiRequests.length > 0, "未捕获页面 AI 主请求");
|
||||
const firstRequest = aiRequests[0];
|
||||
const legacyRunHit = aiRequests.some((entry) => entry.url.includes("/api/ai-agent/run"));
|
||||
const hermesClientHit = aiRequests.some((entry) => entry.url.includes("/api/hermes/client/"));
|
||||
const providerHermes502 = captured.some(
|
||||
(entry) =>
|
||||
entry.phase === "response" &&
|
||||
entry.status === 502 &&
|
||||
/provider|ai_provider_bridge_unavailable|hermes/i.test(entry.body || ""),
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
title,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
firstRequestUrl: firstRequest.url,
|
||||
legacyRunHit,
|
||||
hermesClientHit,
|
||||
providerHermes502,
|
||||
captured,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-mobile-${suffix}`;
|
||||
const sessionId = `mnote_mobile_${suffix}`;
|
||||
const runId = `run_mobile_${suffix}`;
|
||||
const createdIds = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
throw new Error(`移动端页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
title: "移动端问答",
|
||||
traceId: "trace_mobile",
|
||||
persistence: "hermes_on_first_run",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
traceId: "trace_mobile_restore",
|
||||
session: {
|
||||
sessionId,
|
||||
messages: [{ role: "assistant", content: "Mobile restored" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
runId,
|
||||
events: [],
|
||||
traceId: "trace_mobile",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Mobile " })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Mobile response" })}\n\n`,
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
const metrics = await page.locator('[data-testid="wolai-page-ai-drawer"]').evaluate((drawer) => {
|
||||
const panel = drawer.querySelector(".wolai-page-ai-panel");
|
||||
const drawerRect = drawer.getBoundingClientRect();
|
||||
const panelRect = panel ? panel.getBoundingClientRect() : drawerRect;
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
drawerLeft: drawerRect.left,
|
||||
drawerRight: drawerRect.right,
|
||||
panelLeft: panelRect.left,
|
||||
panelRight: panelRect.right,
|
||||
};
|
||||
});
|
||||
assert(metrics.drawerLeft >= 0, `drawer 左侧溢出: ${JSON.stringify(metrics)}`);
|
||||
assert(metrics.drawerRight <= metrics.viewportWidth + 1, `drawer 右侧溢出: ${JSON.stringify(metrics)}`);
|
||||
assert(metrics.panelLeft >= 0, `panel 左侧溢出: ${JSON.stringify(metrics)}`);
|
||||
assert(metrics.panelRight <= metrics.viewportWidth + 1, `panel 右侧溢出: ${JSON.stringify(metrics)}`);
|
||||
assert(metrics.documentWidth <= metrics.viewportWidth + 1, `页面出现横向滚动: ${JSON.stringify(metrics)}`);
|
||||
|
||||
await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Mobile response"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
sessionId,
|
||||
runId,
|
||||
metrics,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-queue-${suffix}`;
|
||||
const sessionId = `mnote_queue_${suffix}`;
|
||||
const runId = `run_queue_${suffix}`;
|
||||
const queueId = `queue_${suffix}`;
|
||||
const createdIds = [];
|
||||
const runBodies = [];
|
||||
const cancelBodies = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/hermes/client/profiles", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, profiles: [{ name: "default", active: true, model: "gpt-5" }] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/profile-memory**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, memory: "", user: "", soul: "" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/tools**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, tools: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, sessionId, title: "队列测试", profile: "default" }),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
session: { sessionId, profile: "default", messages: [] },
|
||||
runtime: { sessionId, runId, status: "running", queueLength: 0 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
runBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
if (runBodies.length === 1) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, runId, sessionId, traceId: `trace_run_${suffix}` }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
queued: true,
|
||||
queueId,
|
||||
sessionId,
|
||||
status: "queued",
|
||||
queueLength: 1,
|
||||
traceId: `trace_queue_${suffix}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/sessions/${sessionId}/queue/${queueId}`, async (route) => {
|
||||
cancelBodies.push({ method: route.request().method(), url: route.request().url() });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, sessionId, queueId, cancelled: true, queueLength: 0 }),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/events/${runId}`, async () => {
|
||||
// 保持第一个 run 处于 active 状态,让第二条输入进入队列。
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`第一条 ${title}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await waitForCondition(() => runBodies.length === 1, UI_TIMEOUT_MS, "未捕获第一条 run 请求");
|
||||
|
||||
await page.locator("[data-page-ai-input]").fill(`第二条 ${title}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("已加入 Hermes 队列"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="runtime"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(`[data-page-ai-queue-item="${queueId}"] [data-page-ai-action="cancel-queued-run"]`).click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1") === false,
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await waitForCondition(() => cancelBodies.length === 1, UI_TIMEOUT_MS, "未捕获取消 queued item 请求");
|
||||
|
||||
assert.equal(runBodies.length, 2, "第二条输入必须到达 mnote-web BFF");
|
||||
assert.equal(cancelBodies.length, 1, "取消 queued item 必须调用 mnote-web queue cancel API");
|
||||
assert.equal(cancelBodies[0].method, "DELETE", "取消 queued item 必须使用 DELETE");
|
||||
assert.equal(runBodies[0].sessionId, sessionId, "第一条 run 必须携带 Hermes sessionId");
|
||||
assert.equal(runBodies[1].sessionId, sessionId, "第二条 queued run 必须携带同一个 sessionId");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
sessionId,
|
||||
runId,
|
||||
queueId,
|
||||
runRequests: runBodies.length,
|
||||
cancelRequests: cancelBodies.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate, timeoutMs, message) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const EXPECTED_SETTINGS_URL =
|
||||
process.env.MNOTE_EXPECTED_HERMES_SETTINGS_URL || "http://127.0.0.1:3999/hermes/settings";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-runtime-${suffix}`;
|
||||
const sessionId = `mnote_runtime_${suffix}`;
|
||||
const runId = `run_runtime_${suffix}`;
|
||||
const createdIds = [];
|
||||
const sessionBodies = [];
|
||||
const runBodies = [];
|
||||
const abortBodies = [];
|
||||
const eventsRelease = deferred();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/hermes/client/profiles", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, profiles: [{ name: "default", active: true, model: "gpt-5" }] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/profile-memory**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, memory: "", user: "", soul: "" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
sessionBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
title: "运行控制测试",
|
||||
profile: "default",
|
||||
traceId: `trace_session_${suffix}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, session: { sessionId, profile: "default", messages: [] } }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
runBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, runId, sessionId, traceId: `trace_run_${suffix}` }),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
|
||||
await eventsRelease.promise;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "run.aborted", run_id: runId, session_id: sessionId })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "SHOULD_NOT_APPEAR_AFTER_ABORT" })}\n\n`,
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/runs/${runId}/abort`, async (route) => {
|
||||
abortBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||
eventsRelease.resolve();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
runId,
|
||||
status: "aborted",
|
||||
runtime: { sessionId, runId, status: "aborted" },
|
||||
events: [
|
||||
{ event: "abort.started", runId },
|
||||
{ event: "abort.completed", runId },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="runtime"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expected) => window.__mnoteHermesSettingsUrl === expected,
|
||||
EXPECTED_SETTINGS_URL,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector("[data-page-ai-tool-list]")?.textContent || "").includes("mnote.page.save"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const settingsDisabled = await page.locator('[data-page-ai-action="open-hermes-settings"]').isDisabled();
|
||||
assert.equal(settingsDisabled, false, "配置 Hermes upstream 后设置入口不应禁用");
|
||||
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-context-scope]").selectOption("options", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`请只读取页面设置 ${title}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "running",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('[data-page-ai-action="stop-run"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "aborted",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForTimeout(100);
|
||||
const drawerTextAfterAbort = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(
|
||||
!drawerTextAfterAbort.includes("SHOULD_NOT_APPEAR_AFTER_ABORT"),
|
||||
"abort 后不应继续追加该 run 的 assistant delta",
|
||||
);
|
||||
|
||||
assert(sessionBodies.some((body) => body.profile === "default"), "创建 session 必须携带 profile");
|
||||
assert(abortBodies.some((body) => body.reason === "page_ai_user_stop"), "停止 run 必须调用 Hermes abort API");
|
||||
assert(runBodies.length > 0, "必须捕获 run 请求");
|
||||
const runBody = runBodies[0];
|
||||
assert.equal(runBody.contextScope, "options", "run 请求必须携带 contextScope");
|
||||
assert.equal(runBody.pageContext.contextScope, "options", "pageContext 必须记录 contextScope");
|
||||
assert.equal(runBody.pageContext.documentBlocks, null, "页面设置 scope 不应携带正文 blocks");
|
||||
assert(runBody.pageContext.pageOptions, "页面设置 scope 必须携带 pageOptions");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
runId,
|
||||
settingsUrl: await page.evaluate(() => window.__mnoteHermesSettingsUrl),
|
||||
runContextScope: runBody.contextScope,
|
||||
abortCount: abortBodies.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
eventsRelease.resolve();
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-smoke-${suffix}`;
|
||||
const sessionId = `mnote_smoke_${suffix}`;
|
||||
const runId = `run_smoke_${suffix}`;
|
||||
let runRequestCount = 0;
|
||||
let sessionDetailHits = 0;
|
||||
const captured = [];
|
||||
const createdIds = [];
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
gateway: { ok: true, status: "mocked", upstream: "http://127.0.0.1:8644" },
|
||||
profile: { name: "default", modelConfigured: true, apiKeyConfigured: true },
|
||||
suggestions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/tools**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, tools: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/profiles", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
active: "default",
|
||||
profiles: [{ name: "default", label: "Default", modelConfigured: true, apiKeyConfigured: true }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
title: "当前页问答",
|
||||
traceId: "trace_smoke",
|
||||
persistence: "hermes_on_first_run",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||
sessionDetailHits += 1;
|
||||
captured.push({ kind: "session-detail", method: route.request().method(), body: "" });
|
||||
assert.equal(route.request().method(), "POST", "刷新恢复必须调用 runtime resume 接口");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
traceId: "trace_restore",
|
||||
session: {
|
||||
sessionId,
|
||||
messages: [],
|
||||
},
|
||||
runtime: {
|
||||
sessionId,
|
||||
runId,
|
||||
status: "completed",
|
||||
profile: "default",
|
||||
documentId: "doc_smoke",
|
||||
traceId: "trace_restore",
|
||||
lastToolName: "mnote.page.get",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
runRequestCount += 1;
|
||||
const currentRunId = `${runId}_${runRequestCount}`;
|
||||
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
runId: currentRunId,
|
||||
events: [],
|
||||
traceId: "trace_smoke",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/events/**", async (route) => {
|
||||
const currentRunId = route.request().url().split("/").pop() || runId;
|
||||
captured.push({ kind: "events", method: route.request().method(), body: "" });
|
||||
const evidenceEvents = Array.from({ length: 24 }, (_, index) => {
|
||||
const callId = `call_smoke_knowledge_rag_${index}`;
|
||||
return (
|
||||
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", args: { query: `evidence ${index}` } })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", summary: `资料库 ${index}`, auditId: `audit_smoke_knowledge_rag_${index}` })}\n\n`
|
||||
);
|
||||
}).join("");
|
||||
if (runRequestCount > 1) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Second " })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "run.completed", run_id: currentRunId, session_id: sessionId, output: "Second response" })}\n\n`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
evidenceEvents +
|
||||
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "tool.failed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Smoke " })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` +
|
||||
`data: ${JSON.stringify({
|
||||
event: "run.completed",
|
||||
run_id: currentRunId,
|
||||
session_id: sessionId,
|
||||
output: "Smoke response",
|
||||
agentAudit: {
|
||||
eventId: "audit_smoke_changed_files",
|
||||
rootUri: "file:///tmp/mnote-smoke",
|
||||
diffSummary: "1 changed file(s)",
|
||||
changedFiles: [
|
||||
{
|
||||
path: "README.md",
|
||||
changeType: "modified",
|
||||
summary: "修改文件 size:10→20 lines:1→2",
|
||||
},
|
||||
],
|
||||
},
|
||||
})}\n\n`,
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Smoke response"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector("[data-page-ai-last-tool]")?.textContent || "").includes("mnote.page.save"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const toolCards = await page.$$eval("[data-page-ai-tool-card]", (cards) =>
|
||||
cards.map((card) => ({
|
||||
id: card.getAttribute("data-page-ai-tool-call-id"),
|
||||
group: card.getAttribute("data-page-ai-tool-group"),
|
||||
status: card.getAttribute("data-page-ai-tool-status"),
|
||||
text: card.textContent || "",
|
||||
})),
|
||||
);
|
||||
assert.equal(toolCards.length, 1, `批量工具调用应折叠成 1 张工具组卡:${JSON.stringify(toolCards)}`);
|
||||
assert(toolCards[0].text.includes("调用工具") && toolCards[0].text.includes("27 个"), `工具组摘要应显示调用数量:${JSON.stringify(toolCards)}`);
|
||||
const toolItems = await page.$$eval("[data-page-ai-tool-item]", (items) =>
|
||||
items.map((item) => ({
|
||||
id: item.getAttribute("data-page-ai-tool-call-id"),
|
||||
status: item.getAttribute("data-page-ai-tool-status"),
|
||||
text: item.textContent || "",
|
||||
})),
|
||||
);
|
||||
assert(toolItems.length >= 27, `工具组展开内容应包含批量工具调用:${JSON.stringify(toolItems)}`);
|
||||
assert(
|
||||
toolItems.some((item) => item.status === "completed" && item.text.includes("call_smoke_page_get")),
|
||||
`缺少 completed 工具项:${JSON.stringify(toolItems)}`,
|
||||
);
|
||||
assert(
|
||||
toolItems.some((item) => item.status === "failed" && item.text.includes("permission_denied")),
|
||||
`缺少 failed 工具项:${JSON.stringify(toolItems)}`,
|
||||
);
|
||||
assert(
|
||||
toolItems.some((item) => item.status === "completed" && item.text.includes("agent.changed_files") && item.text.includes("README.md")),
|
||||
`缺少 changed files 工具项:${JSON.stringify(toolItems)}`,
|
||||
);
|
||||
const toolDetailsOpenByDefault = await page.$$eval("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details", (details) => details.map((node) => node.open));
|
||||
assert.equal(toolDetailsOpenByDefault.length, 1, "工具调用应共用一个 details 折叠容器");
|
||||
assert(toolDetailsOpenByDefault.every((open) => open === false), "工具调用组默认应折叠");
|
||||
const orderState = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => {
|
||||
const toolGroup = node.querySelector("[data-page-ai-tool-card]");
|
||||
const assistant = Array.from(node.querySelectorAll(".wolai-page-ai-message--assistant")).find((item) =>
|
||||
(item.textContent || "").includes("Smoke response")
|
||||
);
|
||||
return {
|
||||
toolBeforeAssistant: Boolean(toolGroup && assistant && (toolGroup.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING)),
|
||||
};
|
||||
});
|
||||
assert(orderState.toolBeforeAssistant, "工具调用组应显示在 AI 输出结果之前");
|
||||
await page.locator("[data-page-ai-tool-card] summary").first().click({ timeout: UI_TIMEOUT_MS });
|
||||
assert(
|
||||
await page.locator("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details").first().evaluate((node) => node.open),
|
||||
"点击工具组 summary 后应展开详情",
|
||||
);
|
||||
const scrollStateBeforeFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => {
|
||||
node.scrollTop = 0;
|
||||
return {
|
||||
scrollTop: node.scrollTop,
|
||||
scrollHeight: node.scrollHeight,
|
||||
clientHeight: node.clientHeight,
|
||||
};
|
||||
});
|
||||
assert(scrollStateBeforeFollowup.scrollHeight > scrollStateBeforeFollowup.clientHeight, `批量工具调用应撑出滚动区:${JSON.stringify(scrollStateBeforeFollowup)}`);
|
||||
await page.locator("[data-page-ai-input]").fill("继续补充一句", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Second response"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const scrollStateAfterFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => ({
|
||||
scrollTop: node.scrollTop,
|
||||
scrollHeight: node.scrollHeight,
|
||||
clientHeight: node.clientHeight,
|
||||
firstToolOpen: Boolean(node.querySelector("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details")?.open),
|
||||
}));
|
||||
assert(scrollStateAfterFollowup.scrollTop <= 4, `用户已上滚时新输出不应强制贴底:${JSON.stringify(scrollStateAfterFollowup)}`);
|
||||
assert(scrollStateAfterFollowup.firstToolOpen, "重渲染后应保留用户展开的工具调用详情");
|
||||
|
||||
const sessionRequest = captured.find((entry) => entry.kind === "session");
|
||||
const runRequest = captured.find((entry) => entry.kind === "run");
|
||||
const eventRequest = captured.find((entry) => entry.kind === "events");
|
||||
assert(sessionRequest, "未捕获 /api/hermes/client/sessions 请求");
|
||||
assert(runRequest, "未捕获 /api/hermes/client/runs 请求");
|
||||
assert(eventRequest, "未捕获 /api/hermes/client/events 请求");
|
||||
const runBody = JSON.parse(runRequest.body);
|
||||
assert.equal(runBody.sessionId, sessionId, "run 请求必须携带 Hermes sessionId");
|
||||
assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId");
|
||||
assert(runBody.pageContext, "run 请求必须携带最小 pageContext");
|
||||
assert.equal(runBody.pageContext.documentBlocks, null, "run 请求不应直接携带页面正文 blocks");
|
||||
assert.equal(runBody.pageContext.subtree, null, "run 请求不应直接携带页面 subtree");
|
||||
assert.equal(runBody.pageContext.outline, null, "run 请求不应直接携带页面 outline");
|
||||
assert(
|
||||
runBody.pageContext.contentAccess,
|
||||
`run 请求必须声明正文访问方式:${JSON.stringify(runBody.pageContext)}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
sessionId,
|
||||
runId,
|
||||
capturedKinds: captured.map((entry) => entry.kind),
|
||||
sessionDetailHits,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
openSectionView,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
UI_TIMEOUT_MS,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const originalTitle = `TEST-HERMES-AI-title-original-${suffix}`;
|
||||
const nextTitle = `TEST-HERMES-AI-title-updated-${suffix}`;
|
||||
const createdIds = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, originalTitle);
|
||||
|
||||
const common = {
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `mnote_smoke_title_${suffix}`,
|
||||
runId: `run_smoke_title_${suffix}`,
|
||||
traceId: `trace_smoke_title_${suffix}`,
|
||||
capabilityScope: ["page.write"],
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
const dryRunTitle = `TEST-HERMES-AI-title-dry-${suffix}`;
|
||||
const titleDryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
...common,
|
||||
dryRun: true,
|
||||
toolName: "mnote.page.update_title",
|
||||
toolCallId: `call_title_dry_${suffix}`,
|
||||
idempotencyKey: `idem_title_dry_${suffix}`,
|
||||
args: { title: dryRunTitle },
|
||||
},
|
||||
});
|
||||
assert.equal(titleDryRun.result.dryRun, true, "标题 dryRun 不应写入");
|
||||
const metaAfterDryRun = await requestJson(
|
||||
context.request,
|
||||
`/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert(!JSON.stringify(metaAfterDryRun).includes(dryRunTitle), "标题 dryRun 后 meta 不应变化");
|
||||
|
||||
const titleResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
...common,
|
||||
toolName: "mnote.page.update_title",
|
||||
toolCallId: `call_title_${suffix}`,
|
||||
idempotencyKey: `idem_title_${suffix}`,
|
||||
args: { title: nextTitle },
|
||||
},
|
||||
});
|
||||
assert.equal(titleResult.result.commandName, "page.head.updateTitle", "标题必须走 page.head.updateTitle");
|
||||
assert.equal(titleResult.audit.commandId, titleResult.result.commandId, "标题 audit commandId 必须指向 Rust commandId");
|
||||
const titleRetry = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
...common,
|
||||
toolName: "mnote.page.update_title",
|
||||
toolCallId: `call_title_${suffix}`,
|
||||
idempotencyKey: `idem_title_${suffix}`,
|
||||
args: { title: nextTitle },
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
titleRetry.result.commandId,
|
||||
titleResult.result.commandId,
|
||||
"标题同一 idempotencyKey 重试必须返回同一个 commandId",
|
||||
);
|
||||
|
||||
const optionsResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
...common,
|
||||
toolName: "mnote.page.update_options",
|
||||
toolCallId: `call_options_${suffix}`,
|
||||
idempotencyKey: `idem_options_${suffix}`,
|
||||
args: { options: { wideLayout: true, smallText: true, pageFont: "serif" } },
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
optionsResult.result.commandName,
|
||||
"page.layout.updateOptions",
|
||||
"页面设置必须走 page.layout.updateOptions",
|
||||
);
|
||||
assert.equal(
|
||||
optionsResult.audit.commandId,
|
||||
optionsResult.result.commandId,
|
||||
"页面设置 audit commandId 必须指向 Rust commandId",
|
||||
);
|
||||
assert(
|
||||
Array.isArray(optionsResult.result.ignoredOptions) &&
|
||||
optionsResult.result.ignoredOptions.includes("pageFont"),
|
||||
"planned/ui_only 页面设置字段必须明确返回 ignoredOptions",
|
||||
);
|
||||
assert(
|
||||
Array.isArray(optionsResult.result.warnings) &&
|
||||
optionsResult.result.warnings.some((warning) => warning && warning.code === "page_option_not_wired"),
|
||||
"planned/ui_only 页面设置字段必须明确返回 warning",
|
||||
);
|
||||
|
||||
const meta = await requestJson(
|
||||
context.request,
|
||||
`/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert(JSON.stringify(meta).includes(nextTitle), "meta 未读到 AI 更新后的标题");
|
||||
assert(JSON.stringify(meta).includes("wide"), "meta 未读到页面设置更新结果");
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await openSectionView(page);
|
||||
await page.waitForFunction(
|
||||
(title) => {
|
||||
const input = document.querySelector('[data-page-title-input="true"]');
|
||||
const current = document.querySelector('[data-page-title-current="true"]');
|
||||
return (input && input.value === title) || (current && current.textContent.includes(title));
|
||||
},
|
||||
nextTitle,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
({ documentId, title }) => {
|
||||
const selectors = [
|
||||
`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`,
|
||||
`.tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"] > .tree-link > .tree-link-title`,
|
||||
];
|
||||
return selectors.some((selector) => {
|
||||
const node = document.querySelector(selector);
|
||||
return node && (node.textContent || "").includes(title);
|
||||
});
|
||||
},
|
||||
{ documentId: target.documentId, title: nextTitle },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const uiState = await page.evaluate((documentId) => {
|
||||
const shell = document.querySelector(".document-shell");
|
||||
const sessionKey = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || "";
|
||||
return {
|
||||
titleInput: document.querySelector('[data-page-title-input="true"]')?.value || "",
|
||||
currentTitle: document.querySelector('[data-page-title-current="true"]')?.textContent || "",
|
||||
sidebarTitle:
|
||||
document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`)
|
||||
?.textContent || "",
|
||||
wideLayout: shell?.getAttribute("data-page-wide-layout") || "",
|
||||
smallText: shell?.getAttribute("data-page-small-text") || "",
|
||||
pageAiSessionOwner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "",
|
||||
pageAiSessionKey: sessionKey,
|
||||
pageAiSessionStorage: sessionKey && window.localStorage ? window.localStorage.getItem(sessionKey) || "" : "",
|
||||
};
|
||||
}, target.documentId);
|
||||
assert(
|
||||
uiState.titleInput === nextTitle || uiState.currentTitle.includes(nextTitle),
|
||||
`刷新后页面 UI 未显示 AI 更新标题: ${JSON.stringify(uiState)}`,
|
||||
);
|
||||
assert(uiState.sidebarTitle.includes(nextTitle), `sidebar 未显示 AI 更新标题: ${JSON.stringify(uiState)}`);
|
||||
assert.equal(uiState.wideLayout, "true", "刷新后 document-shell 未应用 wideLayout");
|
||||
assert.equal(uiState.smallText, "true", "刷新后 document-shell 未应用 smallText");
|
||||
|
||||
await page.locator('[data-testid="wolai-floating-ai"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.documentElement.getAttribute("data-mnote-page-ai-session-owner") === "hermes" &&
|
||||
Boolean(document.documentElement.getAttribute("data-mnote-page-ai-session-key")),
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const aiSessionState = await page.evaluate(() => {
|
||||
const key = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || "";
|
||||
return {
|
||||
owner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "",
|
||||
key,
|
||||
stored: key && window.localStorage ? window.localStorage.getItem(key) || "" : "",
|
||||
pageTitle:
|
||||
document.querySelector('[data-page-title-input="true"]')?.value ||
|
||||
document.querySelector('[data-page-title-current="true"]')?.textContent ||
|
||||
"",
|
||||
};
|
||||
});
|
||||
assert.equal(aiSessionState.owner, "hermes", "页面 AI session owner 必须是 Hermes");
|
||||
const storedSessionState = JSON.parse(aiSessionState.stored || "{}");
|
||||
assert(storedSessionState.activeSessionId, "mnote 本地只应保存 Hermes activeSessionId");
|
||||
assert(!aiSessionState.stored.includes(nextTitle), "mnote 本地 AI session 状态不应保存或驱动页面标题真相");
|
||||
assert(aiSessionState.pageTitle.includes(nextTitle), "页面标题真相必须仍来自页面 UI / Page Aggregate");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
title: nextTitle,
|
||||
titleCommand: titleResult.result.commandName,
|
||||
optionsCommand: optionsResult.result.commandName,
|
||||
ignoredOptions: optionsResult.result.ignoredOptions,
|
||||
warningCodes: optionsResult.result.warnings.map((warning) => warning.code),
|
||||
uiState,
|
||||
aiSessionState,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-tool-${suffix}`;
|
||||
const createdIds = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", {
|
||||
method: "GET",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
});
|
||||
const tools = manifest?.manifest?.tools || [];
|
||||
assert(tools.some((tool) => tool.name === "mnote.page.get"), "manifest 缺少 mnote.page.get");
|
||||
|
||||
const call = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: {
|
||||
toolName: "mnote.page.get",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `mnote_smoke_tool_${suffix}`,
|
||||
runId: `run_smoke_tool_${suffix}`,
|
||||
toolCallId: `call_smoke_tool_${suffix}`,
|
||||
traceId: `trace_smoke_tool_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
includeBody: true,
|
||||
includeOptions: true,
|
||||
includeBlocks: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(call.ok, true, "tool call 应成功");
|
||||
assert.equal(call.toolName, "mnote.page.get", "toolName 不一致");
|
||||
assert.equal(call.toolCallId, `call_smoke_tool_${suffix}`, "toolCallId 不一致");
|
||||
assert.equal(call.result.documentId, target.documentId, "工具结果 documentId 不一致");
|
||||
assert.equal(call.result.workspaceId, target.workspaceId, "工具结果 workspaceId 不一致");
|
||||
assert(call.result.title, "工具结果缺少标题");
|
||||
assert(call.audit && call.audit.effect === "read", "工具结果缺少 read audit");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
toolName: call.toolName,
|
||||
title: call.result.title,
|
||||
audit: call.audit,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function assertPageAiPermissionFailureUi(page, target, suffix) {
|
||||
const sessionId = `mnote_smoke_permission_${suffix}`;
|
||||
const runId = `run_smoke_permission_${suffix}`;
|
||||
const sessionRoute = "**/api/hermes/client/sessions";
|
||||
const sessionDetailRoute = `**/api/hermes/client/sessions/${sessionId}`;
|
||||
const runsRoute = "**/api/hermes/client/runs";
|
||||
const eventsRoute = `**/api/hermes/client/events/${runId}`;
|
||||
|
||||
await page.route(sessionRoute, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
title: "权限失败 UI 验证",
|
||||
traceId: `trace_permission_${suffix}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(sessionDetailRoute, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
session: { sessionId, messages: [] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(runsRoute, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
upstream: { run_id: runId, status: "started" },
|
||||
traceId: `trace_permission_${suffix}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(eventsRoute, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, tool: "mnote.page.save" })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, tool: "mnote.page.save", error: { code: "permission_denied" } })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "permission_denied" })}\n\n`,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill("权限失败 UI 验证", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return text.includes("mnote.page.save") && text.includes("permission_denied");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
} finally {
|
||||
await page.unroute(sessionRoute).catch(() => undefined);
|
||||
await page.unroute(sessionDetailRoute).catch(() => undefined);
|
||||
await page.unroute(runsRoute).catch(() => undefined);
|
||||
await page.unroute(eventsRoute).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const title = `TEST-HERMES-AI-write-${suffix}`;
|
||||
const marker = `TEST-HERMES-AI-WRITEBACK-${suffix}`;
|
||||
const createdIds = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const baseTool = {
|
||||
toolName: "mnote.page.save",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId: "smoke-user",
|
||||
sessionId: `mnote_smoke_write_${suffix}`,
|
||||
runId: `run_smoke_write_${suffix}`,
|
||||
toolCallId: `call_smoke_write_${suffix}`,
|
||||
traceId: `trace_smoke_write_${suffix}`,
|
||||
idempotencyKey: `idem_smoke_write_${suffix}`,
|
||||
capabilityScope: ["page.write"],
|
||||
args: {
|
||||
mode: "replace",
|
||||
content: [
|
||||
{
|
||||
id: `block_${suffix}`,
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: marker }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-mnote-actor-id": "smoke-user",
|
||||
"x-mnote-workspace-id": "ws_denied",
|
||||
},
|
||||
data: JSON.stringify({
|
||||
...baseTool,
|
||||
traceId: `trace_smoke_write_denied_${suffix}`,
|
||||
toolCallId: `call_smoke_write_denied_${suffix}`,
|
||||
idempotencyKey: `idem_smoke_write_denied_${suffix}`,
|
||||
}),
|
||||
});
|
||||
const deniedText = await deniedResponse.text();
|
||||
assert.equal(deniedResponse.status(), 403, "workspace 上下文冲突应返回 403");
|
||||
assert(deniedText.includes("workspace_context_conflict"), "权限失败应返回稳定 workspace_context_conflict");
|
||||
assert(!deniedText.includes(title), "权限失败响应不应泄露页面标题");
|
||||
assert(!deniedText.includes(marker), "权限失败响应不应泄露正文内容");
|
||||
await assertPageAiPermissionFailureUi(page, target, suffix);
|
||||
|
||||
const dryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: { ...baseTool, dryRun: true, idempotencyKey: `idem_smoke_write_dry_${suffix}` },
|
||||
});
|
||||
assert.equal(dryRun.result.dryRun, true, "dryRun 不应写入");
|
||||
const dryRunContent = await requestJson(
|
||||
context.request,
|
||||
`/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert(!JSON.stringify(dryRunContent).includes(marker), "dryRun 后不应读到 AI 写入标记");
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
|
||||
.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS })
|
||||
.catch(() => undefined);
|
||||
|
||||
const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: { ...baseTool, dryRun: false },
|
||||
});
|
||||
assert.equal(write.ok, true, "写入 tool call 应成功");
|
||||
assert.equal(write.audit.effect, "write", "写入 audit effect 应为 write");
|
||||
assert.equal(write.result.commandName, "page.body.save", "写入必须走 page.body.save");
|
||||
assert(write.result.commandId, "写入结果必须包含 Rust commandId");
|
||||
assert.equal(write.audit.commandId, write.result.commandId, "audit commandId 必须指向 Rust commandId");
|
||||
await page.waitForFunction((expected) => (document.body.textContent || "").includes(expected), marker, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const retry = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "smoke-user" },
|
||||
data: { ...baseTool, dryRun: false },
|
||||
});
|
||||
assert.equal(retry.ok, true, "幂等重试 tool call 应成功");
|
||||
assert.equal(retry.result.commandId, write.result.commandId, "同一 idempotencyKey 重试必须返回同一个 commandId");
|
||||
assert.equal(retry.audit.commandId, write.audit.commandId, "同一 idempotencyKey 重试必须返回同一个 audit commandId");
|
||||
|
||||
const content = await requestJson(
|
||||
context.request,
|
||||
`/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert(JSON.stringify(content).includes(marker), "刷新读取内容后未找到 AI 写入标记");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
marker,
|
||||
commandName: write.result.commandName,
|
||||
audit: write.audit,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Regression: page-tree expand/collapse icons + persistence + no duplicate rows.
|
||||
* Builds a liaibo-like nested local_folder tree, exercises expand/collapse/reload.
|
||||
*/
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
|
||||
|
||||
const TASK = "task-page-tree-expand-persist-regression";
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 60_000);
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK);
|
||||
const BINARY =
|
||||
process.env.MNOTE_WEB_BIN ||
|
||||
path.join(__dirname, "..", "rust", "target", "debug", "mnote-web");
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`server_not_ready: ${url}`));
|
||||
return;
|
||||
}
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 250);
|
||||
});
|
||||
request.on("error", () => setTimeout(tick, 250));
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function writeTree(root) {
|
||||
// Mirror user screenshot shape: root page-group + several sibling folders.
|
||||
const space = path.join(root, "liaibo的个人空间");
|
||||
const mk = (p, content = null) => {
|
||||
if (content == null) {
|
||||
fs.mkdirSync(p, { recursive: true });
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content, "utf8");
|
||||
};
|
||||
mk(space);
|
||||
mk(path.join(space, "liaibo的个人空间.md"), "# liaibo的个人空间\n");
|
||||
for (const name of ["知识", "项目", "工作资料", "日常", "剪藏", "个人"]) {
|
||||
mk(path.join(space, name));
|
||||
mk(path.join(space, name, `${name}.md`), `# ${name}\n`);
|
||||
}
|
||||
// Nested under 个人 (matches screenshot after expand)
|
||||
for (const name of ["正版软件备份", "个人发展", "杂记", "密码"]) {
|
||||
mk(path.join(space, "个人", name));
|
||||
mk(path.join(space, "个人", name, `${name}.md`), `# ${name}\n`);
|
||||
}
|
||||
// Nested under 知识
|
||||
for (const name of ["有机化学", "文章"]) {
|
||||
mk(path.join(space, "知识", name));
|
||||
mk(path.join(space, "知识", name, `${name}.md`), `# ${name}\n`);
|
||||
}
|
||||
return space;
|
||||
}
|
||||
|
||||
async function signUp(context, baseUrl, actorId) {
|
||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
name: actorId,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signUp",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(response.ok(), `signUp failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function openDefaultLocalWorkspace(page, baseUrl) {
|
||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
||||
if (await createButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
const rootUri =
|
||||
new URL(page.url()).searchParams.get("rootUri") ||
|
||||
(await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""));
|
||||
assert.ok(rootUri, "should open local_folder workspace");
|
||||
return {
|
||||
rootUri,
|
||||
workspaceId: new URL(page.url()).searchParams.get("workspaceId") || "",
|
||||
};
|
||||
}
|
||||
|
||||
function fileUrlToPath(rootUri) {
|
||||
assert.ok(rootUri.startsWith("file://"), `unexpected rootUri ${rootUri}`);
|
||||
return decodeURIComponent(rootUri.slice("file://".length));
|
||||
}
|
||||
|
||||
async function reloadWorkspace(page, baseUrl, rootUri, workspaceId) {
|
||||
const url = new URL(baseUrl);
|
||||
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
function reportTree(page) {
|
||||
return page.evaluate(() => {
|
||||
function computeChevron(row) {
|
||||
const toggle = row.querySelector("[data-rust-action='toggle']");
|
||||
if (!toggle) return null;
|
||||
const before = window.getComputedStyle(toggle, "::before");
|
||||
const transform = before.transform || "";
|
||||
// matrix(0,1,-1,0,0,0) ~= rotate(90deg)
|
||||
const rotated =
|
||||
transform.includes("matrix(0, 1, -1, 0") ||
|
||||
transform.includes("matrix(0,1,-1,0") ||
|
||||
(transform.includes("rotate") && !transform.includes("rotate(0"));
|
||||
return { transform, rotated };
|
||||
}
|
||||
const rows = Array.from(
|
||||
document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")
|
||||
);
|
||||
const byId = new Map();
|
||||
const details = rows.map((row) => {
|
||||
const nodeId = row.getAttribute("data-node-id") || "";
|
||||
byId.set(nodeId, (byId.get(nodeId) || 0) + 1);
|
||||
const node = row.closest(".tree-node");
|
||||
const children = node && node.querySelector(":scope > .tree-children");
|
||||
const toggle = row.querySelector("[data-rust-action='toggle']");
|
||||
const title = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim();
|
||||
const chevron = computeChevron(row);
|
||||
const collapsed = children ? children.classList.contains("tree-children--collapsed") : null;
|
||||
const childCount = children ? children.children.length : 0;
|
||||
const visuallyOpen = collapsed === false && childCount > 0;
|
||||
const aria = row.getAttribute("aria-expanded");
|
||||
const dataExpanded = row.getAttribute("data-expanded");
|
||||
const iconSaysOpen = chevron ? chevron.rotated : null;
|
||||
return {
|
||||
nodeId,
|
||||
title,
|
||||
parentId: row.getAttribute("data-parent-id") || "",
|
||||
depth: Number(row.getAttribute("data-depth") || 0),
|
||||
aria,
|
||||
dataExpanded,
|
||||
toggleAria: toggle ? toggle.getAttribute("aria-expanded") : null,
|
||||
toggleData: toggle ? toggle.getAttribute("data-expanded") : null,
|
||||
collapsed,
|
||||
childCount,
|
||||
visuallyOpen,
|
||||
iconSaysOpen,
|
||||
desync:
|
||||
toggle != null &&
|
||||
((iconSaysOpen === true && !visuallyOpen) ||
|
||||
(iconSaysOpen === false && visuallyOpen) ||
|
||||
aria !== dataExpanded ||
|
||||
(aria === "true") !== visuallyOpen),
|
||||
};
|
||||
});
|
||||
const dups = [...byId.entries()].filter(([, n]) => n > 1).map(([id, n]) => ({ id, n }));
|
||||
const titles = details.map((d) => d.title);
|
||||
const titleCounts = titles.reduce((m, t) => {
|
||||
m[t] = (m[t] || 0) + 1;
|
||||
return m;
|
||||
}, {});
|
||||
// top-level (depth 0 or parent empty) duplicates by title are the screenshot bug
|
||||
const topTitles = details.filter((d) => !d.parentId || d.depth === 0).map((d) => d.title);
|
||||
const topTitleCounts = topTitles.reduce((m, t) => {
|
||||
m[t] = (m[t] || 0) + 1;
|
||||
return m;
|
||||
}, {});
|
||||
const ls = {};
|
||||
for (const k of Object.keys(localStorage)) {
|
||||
if (!k.includes("sidebarTreeViewState")) continue;
|
||||
try {
|
||||
ls[k.slice(-60)] = JSON.parse(localStorage.getItem(k) || "null");
|
||||
} catch {
|
||||
ls[k.slice(-60)] = localStorage.getItem(k);
|
||||
}
|
||||
}
|
||||
return {
|
||||
href: location.href,
|
||||
rowCount: rows.length,
|
||||
dups,
|
||||
topTitleCounts,
|
||||
desync: details.filter((d) => d.desync),
|
||||
openRows: details.filter((d) => d.visuallyOpen).map((d) => d.title),
|
||||
sample: details,
|
||||
markers: {
|
||||
applied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied"),
|
||||
synced: document.documentElement.getAttribute("data-mnote-page-tree-expand-synced"),
|
||||
restored: document.documentElement.getAttribute("data-mnote-page-tree-expansion-restored"),
|
||||
saved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved"),
|
||||
loadErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-load-error"),
|
||||
saveErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-save-error"),
|
||||
},
|
||||
ls,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function clickToggleByTitle(page, title) {
|
||||
return page.evaluate((want) => {
|
||||
const rows = Array.from(
|
||||
document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")
|
||||
);
|
||||
for (const row of rows) {
|
||||
const t = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim();
|
||||
if (t !== want) continue;
|
||||
const btn = row.querySelector("[data-rust-action='toggle']");
|
||||
if (!btn) return { ok: false, reason: "no_toggle" };
|
||||
btn.click();
|
||||
return { ok: true, nodeId: row.getAttribute("data-node-id") };
|
||||
}
|
||||
return { ok: false, reason: "not_found" };
|
||||
}, title);
|
||||
}
|
||||
|
||||
async function waitDesyncClear(page, timeoutMs = 3000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const r = await reportTree(page);
|
||||
if (r.desync.length === 0) return r;
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
return reportTree(page);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(RESULT_DIR, { recursive: true });
|
||||
if (!fs.existsSync(BINARY)) {
|
||||
throw new Error(`binary missing: ${BINARY} (build mnote-web first)`);
|
||||
}
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-expand-reg-"));
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const cpEnv = buildControlPlaneTestEnv(dataRoot);
|
||||
const env = {
|
||||
...process.env,
|
||||
...cpEnv,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
RUST_LOG: process.env.RUST_LOG || "warn",
|
||||
};
|
||||
const child = spawn(BINARY, [], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let serverLog = "";
|
||||
child.stdout.on("data", (d) => {
|
||||
serverLog += d.toString();
|
||||
});
|
||||
child.stderr.on("data", (d) => {
|
||||
serverLog += d.toString();
|
||||
});
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: resolveChromiumExecutablePath() || undefined,
|
||||
});
|
||||
const results = { steps: [] };
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, 45_000);
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
const actorId = `expand-reg-${Date.now()}`;
|
||||
await signUp(context, baseUrl, actorId);
|
||||
const page = await context.newPage();
|
||||
const puts = [];
|
||||
const gets = [];
|
||||
page.on("response", async (res) => {
|
||||
if (!res.url().includes("/api/tree/view-state")) return;
|
||||
const method = res.request().method();
|
||||
let body = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {}
|
||||
const entry = {
|
||||
method,
|
||||
status: res.status(),
|
||||
expandedIds: body && body.result && body.result.state && body.result.state.expandedIds,
|
||||
};
|
||||
if (method === "PUT") puts.push(entry);
|
||||
else gets.push(entry);
|
||||
});
|
||||
|
||||
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
|
||||
const rootPath = fileUrlToPath(rootUri);
|
||||
// Seed liaibo-like tree into the default local workspace.
|
||||
writeTree(rootPath);
|
||||
await reloadWorkspace(page, baseUrl, rootUri, workspaceId);
|
||||
await page.screenshot({ path: path.join(RESULT_DIR, "01-initial.png") });
|
||||
let r = await reportTree(page);
|
||||
results.steps.push({ name: "initial", r });
|
||||
assert.equal(r.dups.length, 0, `initial dups: ${JSON.stringify(r.dups)}`);
|
||||
assert.equal(r.desync.length, 0, `initial desync: ${JSON.stringify(r.desync)}`);
|
||||
// Default: expandable rows closed + ▶ (ignore non-toggle leaves)
|
||||
for (const d of r.sample.filter((x) => x.toggleAria != null)) {
|
||||
assert.equal(d.visuallyOpen, false, `initial open ${d.title}`);
|
||||
assert.equal(d.iconSaysOpen, false, `initial icon open ${d.title}`);
|
||||
assert.equal(d.aria, "false", `initial aria ${d.title}`);
|
||||
}
|
||||
|
||||
// Expand root then 个人
|
||||
let click = await clickToggleByTitle(page, "liaibo的个人空间");
|
||||
assert.equal(click.ok, true, `expand root: ${JSON.stringify(click)}`);
|
||||
await page.waitForTimeout(700);
|
||||
click = await clickToggleByTitle(page, "个人");
|
||||
assert.equal(click.ok, true, `expand 个人: ${JSON.stringify(click)}`);
|
||||
await page.waitForTimeout(900);
|
||||
await page.waitForTimeout(700);
|
||||
await page.screenshot({ path: path.join(RESULT_DIR, "02-expanded.png") });
|
||||
r = await waitDesyncClear(page);
|
||||
results.steps.push({ name: "expanded", r, puts: puts.slice(-2) });
|
||||
assert.equal(r.dups.length, 0, `expanded dups: ${JSON.stringify(r.dups)}`);
|
||||
assert.equal(r.desync.length, 0, `expanded desync: ${JSON.stringify(r.desync)}`);
|
||||
assert.ok(r.openRows.includes("liaibo的个人空间"), "root should be open");
|
||||
assert.ok(r.openRows.includes("个人"), "个人 should be open");
|
||||
const personalChildren = r.sample.filter((d) => d.title === "正版软件备份");
|
||||
assert.equal(personalChildren.length, 1, `个人 children dup: ${JSON.stringify(personalChildren)}`);
|
||||
|
||||
// Collapse 个人 only
|
||||
click = await clickToggleByTitle(page, "个人");
|
||||
assert.equal(click.ok, true);
|
||||
await page.waitForTimeout(500);
|
||||
r = await waitDesyncClear(page);
|
||||
results.steps.push({ name: "collapsed-personal", r });
|
||||
assert.equal(r.dups.length, 0, `collapse dups ${JSON.stringify(r.dups)}`);
|
||||
assert.equal(r.desync.length, 0, `collapse desync ${JSON.stringify(r.desync)}`);
|
||||
const personal = r.sample.find((d) => d.title === "个人");
|
||||
assert.ok(personal, "个人 row missing");
|
||||
assert.equal(personal.visuallyOpen, false, "个人 should be closed");
|
||||
assert.equal(personal.iconSaysOpen, false, "个人 icon should be ▶");
|
||||
assert.equal(personal.aria, "false");
|
||||
const titleCountsUnderRoot = {};
|
||||
for (const d of r.sample) {
|
||||
if (d.depth === 1) titleCountsUnderRoot[d.title] = (titleCountsUnderRoot[d.title] || 0) + 1;
|
||||
}
|
||||
for (const [title, count] of Object.entries(titleCountsUnderRoot)) {
|
||||
assert.equal(count, 1, `duplicate depth1 title ${title} count=${count}`);
|
||||
}
|
||||
|
||||
// Re-expand 个人, then reload — should restore open state
|
||||
click = await clickToggleByTitle(page, "个人");
|
||||
assert.equal(click.ok, true);
|
||||
await page.waitForTimeout(1000);
|
||||
r = await reportTree(page);
|
||||
const lsValues = Object.values(r.ls);
|
||||
const hasLocal =
|
||||
lsValues.some((v) => v && Array.isArray(v.expandedIds) && v.expandedIds.length > 0) ||
|
||||
puts.some((p) => p.status === 200);
|
||||
assert.ok(hasLocal || puts.length > 0, `persist missing ls=${JSON.stringify(r.ls)} puts=${puts.length}`);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
await page.screenshot({ path: path.join(RESULT_DIR, "03-after-reload.png") });
|
||||
r = await waitDesyncClear(page, 5000);
|
||||
results.steps.push({ name: "after-reload-open", r, gets: gets.slice(-4), puts: puts.slice(-2) });
|
||||
assert.equal(r.dups.length, 0, `reload dups: ${JSON.stringify(r.dups)}`);
|
||||
assert.equal(r.desync.length, 0, `reload desync: ${JSON.stringify(r.desync)}`);
|
||||
assert.ok(
|
||||
r.openRows.includes("liaibo的个人空间") && r.openRows.includes("个人"),
|
||||
`reload should restore expands, openRows=${JSON.stringify(r.openRows)} markers=${JSON.stringify(r.markers)} ls=${JSON.stringify(r.ls)}`
|
||||
);
|
||||
// Nested children of 个人 must actually reappear after cascade hydrate.
|
||||
const afterReloadPersonalChild = r.sample.filter((d) => d.title === "正版软件备份");
|
||||
assert.equal(
|
||||
afterReloadPersonalChild.length,
|
||||
1,
|
||||
`reload missing nested child: ${JSON.stringify(afterReloadPersonalChild)} sample=${JSON.stringify(
|
||||
r.sample.map((d) => ({ t: d.title, d: d.depth, open: d.visuallyOpen, children: d.childCount }))
|
||||
)}`
|
||||
);
|
||||
const personalAfterReload = r.sample.find((d) => d.title === "个人");
|
||||
assert.ok(personalAfterReload && personalAfterReload.childCount >= 4, `个人 childCount after reload ${personalAfterReload?.childCount}`);
|
||||
assert.equal(personalAfterReload.iconSaysOpen, true, "个人 icon should be ▼ after restore");
|
||||
const d1 = {};
|
||||
for (const d of r.sample) {
|
||||
if (d.depth === 1) d1[d.title] = (d1[d.title] || 0) + 1;
|
||||
}
|
||||
for (const [title, count] of Object.entries(d1)) {
|
||||
assert.equal(count, 1, `reload duplicate depth1 ${title}=${count}`);
|
||||
}
|
||||
|
||||
// Collapse all expanded, reload — icons must stay ▶
|
||||
for (const title of ["个人", "liaibo的个人空间"]) {
|
||||
const row = r.sample.find((d) => d.title === title);
|
||||
if (row && row.visuallyOpen) {
|
||||
await clickToggleByTitle(page, title);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(900);
|
||||
r = await waitDesyncClear(page);
|
||||
results.steps.push({ name: "all-collapsed", r });
|
||||
assert.equal(r.desync.length, 0, `all-collapsed desync ${JSON.stringify(r.desync)}`);
|
||||
for (const d of r.sample.filter((x) => x.toggleAria != null)) {
|
||||
assert.equal(d.visuallyOpen, false, `should be closed ${d.title}`);
|
||||
assert.equal(d.iconSaysOpen, false, `icon should be ▶ for ${d.title}`);
|
||||
}
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
await page.screenshot({ path: path.join(RESULT_DIR, "04-reload-collapsed.png") });
|
||||
r = await waitDesyncClear(page, 5000);
|
||||
results.steps.push({ name: "after-reload-collapsed", r });
|
||||
assert.equal(r.dups.length, 0, `collapsed-reload dups ${JSON.stringify(r.dups)}`);
|
||||
assert.equal(r.desync.length, 0, `collapsed-reload desync ${JSON.stringify(r.desync)}`);
|
||||
assert.deepEqual(r.openRows, [], `collapsed reload openRows=${JSON.stringify(r.openRows)}`);
|
||||
for (const d of r.sample.filter((x) => x.toggleAria != null)) {
|
||||
assert.equal(d.iconSaysOpen, false, `collapsed reload icon open for ${d.title}`);
|
||||
assert.equal(d.aria, "false", `collapsed reload aria for ${d.title}`);
|
||||
assert.equal(d.dataExpanded, "false", `collapsed reload data-expanded for ${d.title}`);
|
||||
}
|
||||
|
||||
results.ok = true;
|
||||
results.puts = puts;
|
||||
results.gets = gets.slice(-6);
|
||||
fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
resultDir: RESULT_DIR,
|
||||
openAfterExpand: results.steps.find((s) => s.name === "expanded")?.r?.openRows,
|
||||
afterReload: results.steps.find((s) => s.name === "after-reload-open")?.r?.openRows,
|
||||
afterCollapsedReload: results.steps.find((s) => s.name === "after-reload-collapsed")?.r
|
||||
?.openRows,
|
||||
putCount: puts.length,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
results.ok = false;
|
||||
results.error = String((error && error.stack) || error);
|
||||
fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2));
|
||||
fs.writeFileSync(path.join(RESULT_DIR, "server.log"), serverLog);
|
||||
console.error(results.error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {}
|
||||
}, 1000).unref();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
// S1: management defaultModel → effective → Pi status/start truth.
|
||||
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 (dev:hot default).
|
||||
// Prefer MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock for a fast local loop.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE = process.env.MNOTE_S1_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||||
const ACTOR_ID = process.env.MNOTE_S1_ACTOR_ID || "s1-effective-model-admin";
|
||||
const AUTH = process.env.MNOTE_S1_AUTH || "Bearer s1-effective-model";
|
||||
const ROOT =
|
||||
process.env.MNOTE_S1_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s1-effective-model-"));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function seedWorkspace(rootUri, rootPath) {
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: "admin",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: `local-ws:${ACTOR_ID}:s1`,
|
||||
workspace_name: "S1 effective model",
|
||||
root_uri: rootUri,
|
||||
root_path: rootPath,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s1_effective_model_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
"S1 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web(npm run dev:hot 默认开启)。",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
seed.status === 200 && seed.body.ok === true,
|
||||
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT, "s1.md"), "# S1 effective model\n", "utf8");
|
||||
const rootUri = `file://${ROOT}`;
|
||||
|
||||
console.log(`\n🧪 S1 effective model → Pi start (base=${BASE}, actor=${ACTOR_ID})\n`);
|
||||
|
||||
await seedWorkspace(rootUri, ROOT);
|
||||
|
||||
// Baseline admin settings (global owner for admin actor).
|
||||
const adminBefore = await fetchJson(`${BASE}/api/ai-admin/settings`);
|
||||
assert(adminBefore.status === 200 && adminBefore.body.ok === true, `admin get: ${adminBefore.status} ${JSON.stringify(adminBefore.body)}`);
|
||||
const originalDefault = String(adminBefore.body.defaultModel || "omniroute/freefirst");
|
||||
let allowed = Array.isArray(adminBefore.body.allowedModels)
|
||||
? adminBefore.body.allowedModels.map(String)
|
||||
: [];
|
||||
// Ensure both candidates exist for the switch.
|
||||
for (const model of ["omniroute/freefirst", "omniroute/gpt-5.4-mini", "omniroute/pi-fast", "omniroute/pi-reason"]) {
|
||||
if (!allowed.includes(model)) allowed.push(model);
|
||||
}
|
||||
|
||||
const targetA = "omniroute/pi-fast";
|
||||
const targetB = "omniroute/pi-reason";
|
||||
console.log(` original defaultModel=${originalDefault}`);
|
||||
|
||||
// ── Phase A: set default to pi-fast ─────────────────────────────────
|
||||
const putA = await fetchJson(`${BASE}/api/ai-admin/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
defaultModel: targetA,
|
||||
allowedModels: allowed,
|
||||
}),
|
||||
});
|
||||
assert(putA.status === 200 && putA.body.ok === true, `admin put A: ${putA.status} ${JSON.stringify(putA.body)}`);
|
||||
|
||||
const effectiveA = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||||
assert(effectiveA.status === 200, `effective A: ${effectiveA.status}`);
|
||||
assert(
|
||||
String(effectiveA.body.defaultModel) === targetA,
|
||||
`effective A defaultModel=${effectiveA.body.defaultModel} expected ${targetA}`,
|
||||
);
|
||||
console.log(` [A] effective.defaultModel=${effectiveA.body.defaultModel}`);
|
||||
|
||||
const statusA = await fetchJson(`${BASE}/api/page-ai/pi/status`);
|
||||
assert(statusA.status === 200 && statusA.body.ok === true, `status A: ${statusA.status} ${JSON.stringify(statusA.body)}`);
|
||||
const statusRefA = String(
|
||||
statusA.body.defaultModel ||
|
||||
`${statusA.body.defaultModelProvider}/${statusA.body.defaultModelId}`,
|
||||
);
|
||||
assert(
|
||||
statusRefA === targetA,
|
||||
`status A default ${statusRefA} != ${targetA}`,
|
||||
);
|
||||
console.log(` [A] status.defaultModel=${statusRefA}`);
|
||||
|
||||
const startA = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId: `pi_lab_s1_a_${Date.now()}`,
|
||||
rootUri,
|
||||
// omit modelProvider/modelId → must use default
|
||||
}),
|
||||
});
|
||||
assert(startA.status === 200 && startA.body.ok === true, `start A: ${startA.status} ${JSON.stringify(startA.body)}`);
|
||||
const sessionA = startA.body.session || {};
|
||||
const startedA = `${sessionA.modelProvider}/${sessionA.modelId}`;
|
||||
assert(startedA === targetA, `start A model ${startedA} != ${targetA}`);
|
||||
console.log(` [A] start session model=${startedA}`);
|
||||
|
||||
// ── Phase B: change default to pi-reason; next start must pick it up ─
|
||||
const putB = await fetchJson(`${BASE}/api/ai-admin/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
defaultModel: targetB,
|
||||
allowedModels: allowed,
|
||||
}),
|
||||
});
|
||||
assert(putB.status === 200 && putB.body.ok === true, `admin put B: ${putB.status} ${JSON.stringify(putB.body)}`);
|
||||
|
||||
const effectiveB = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||||
assert(String(effectiveB.body.defaultModel) === targetB, `effective B=${effectiveB.body.defaultModel}`);
|
||||
console.log(` [B] effective.defaultModel=${effectiveB.body.defaultModel}`);
|
||||
|
||||
const statusB = await fetchJson(`${BASE}/api/page-ai/pi/status`);
|
||||
const statusRefB = String(
|
||||
statusB.body.defaultModel ||
|
||||
`${statusB.body.defaultModelProvider}/${statusB.body.defaultModelId}`,
|
||||
);
|
||||
assert(statusRefB === targetB, `status B default ${statusRefB} != ${targetB}`);
|
||||
console.log(` [B] status.defaultModel=${statusRefB}`);
|
||||
|
||||
const startB = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId: `pi_lab_s1_b_${Date.now()}`,
|
||||
rootUri,
|
||||
}),
|
||||
});
|
||||
assert(startB.status === 200 && startB.body.ok === true, `start B: ${startB.status} ${JSON.stringify(startB.body)}`);
|
||||
const sessionB = startB.body.session || {};
|
||||
const startedB = `${sessionB.modelProvider}/${sessionB.modelId}`;
|
||||
assert(
|
||||
startedB === targetB,
|
||||
`start B model ${startedB} != ${targetB} (policy change must apply on next start)`,
|
||||
);
|
||||
console.log(` [B] start session model=${startedB}`);
|
||||
|
||||
// Best-effort restore
|
||||
if (originalDefault) {
|
||||
await fetchJson(`${BASE}/api/ai-admin/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ defaultModel: originalDefault, allowedModels: allowed }),
|
||||
});
|
||||
console.log(` restored defaultModel=${originalDefault}`);
|
||||
}
|
||||
|
||||
console.log("\n✅ S1 effective model → Pi start smoke passed\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S1 smoke failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
// S2: skills/MCP delete → removedDefault* tombstones; effective has no ghosts.
|
||||
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1.
|
||||
// Prefer MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock for a fast local loop.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE = process.env.MNOTE_S2_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||||
const ACTOR_ID = process.env.MNOTE_S2_ACTOR_ID || "s2-skills-mcp-admin";
|
||||
const AUTH = process.env.MNOTE_S2_AUTH || "Bearer s2-skills-mcp";
|
||||
const ROOT =
|
||||
process.env.MNOTE_S2_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s2-skills-mcp-"));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function seedWorkspace(rootUri, rootPath) {
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: "admin",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: `local-ws:${ACTOR_ID}:s2`,
|
||||
workspace_name: "S2 skills MCP delete",
|
||||
root_uri: rootUri,
|
||||
root_path: rootPath,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s2_skills_mcp_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
"S2 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
seed.status === 200 && seed.body.ok === true,
|
||||
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function skillIds(listOrMap) {
|
||||
if (Array.isArray(listOrMap)) {
|
||||
return listOrMap.map((s) => String(s.name || s.id || "")).filter(Boolean);
|
||||
}
|
||||
if (listOrMap && typeof listOrMap === "object") {
|
||||
return Object.keys(listOrMap);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function mcpIds(listOrMap) {
|
||||
if (Array.isArray(listOrMap)) {
|
||||
return listOrMap.map((s) => String(s.name || s.id || "")).filter(Boolean);
|
||||
}
|
||||
if (listOrMap && typeof listOrMap === "object") {
|
||||
return Object.keys(listOrMap);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT, "s2.md"), "# S2 skills MCP delete\n", "utf8");
|
||||
const rootUri = `file://${ROOT}`;
|
||||
|
||||
console.log(`\n🧪 S2 skills/MCP delete no ghosts (base=${BASE}, actor=${ACTOR_ID})\n`);
|
||||
|
||||
await seedWorkspace(rootUri, ROOT);
|
||||
|
||||
const adminBefore = await fetchJson(`${BASE}/api/ai-admin/settings`);
|
||||
assert(
|
||||
adminBefore.status === 200 && adminBefore.body.ok === true,
|
||||
`admin get: ${adminBefore.status} ${JSON.stringify(adminBefore.body)}`,
|
||||
);
|
||||
|
||||
const beforeSkills = skillIds(adminBefore.body.skills);
|
||||
const beforeMcps = mcpIds(adminBefore.body.mcpServers);
|
||||
assert(beforeSkills.includes("vpn") || beforeSkills.length > 0, "expected default skills in admin");
|
||||
console.log(` baseline skills=${beforeSkills.length} mcp=${beforeMcps.length}`);
|
||||
|
||||
// Keep only a custom skill + custom mcp; omit all defaults → tombstones.
|
||||
const keepSkill = {
|
||||
"s2-keep-skill": {
|
||||
name: "S2 Keep Skill",
|
||||
enabled: true,
|
||||
description: "s2 keep",
|
||||
source: path.join(ROOT, "SKILL.md"),
|
||||
riskLevel: "low",
|
||||
requiredScopes: [],
|
||||
},
|
||||
};
|
||||
const keepMcp = {
|
||||
"s2-keep-mcp": {
|
||||
name: "S2 Keep MCP",
|
||||
enabled: true,
|
||||
url: "",
|
||||
transport: "stdio",
|
||||
command: "s2-keep-mcp",
|
||||
networkPolicy: "deny-all",
|
||||
secretRefs: [],
|
||||
facadeOnly: true,
|
||||
sandbox: true,
|
||||
description: "s2 keep mcp",
|
||||
riskLevel: "medium",
|
||||
requiredScopes: [],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(ROOT, "SKILL.md"), "# S2 Keep\n", "utf8");
|
||||
|
||||
const put = await fetchJson(`${BASE}/api/ai-admin/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
skills: keepSkill,
|
||||
mcpServers: keepMcp,
|
||||
}),
|
||||
});
|
||||
assert(put.status === 200 && put.body.ok === true, `admin put: ${put.status} ${JSON.stringify(put.body)}`);
|
||||
|
||||
const adminAfter = await fetchJson(`${BASE}/api/ai-admin/settings`);
|
||||
assert(adminAfter.status === 200 && adminAfter.body.ok === true, `admin after: ${adminAfter.status}`);
|
||||
const afterSkills = skillIds(adminAfter.body.skills);
|
||||
const afterMcps = mcpIds(adminAfter.body.mcpServers);
|
||||
assert(
|
||||
afterSkills.includes("s2-keep-skill") || afterSkills.includes("S2 Keep Skill"),
|
||||
`admin skills missing keep: ${JSON.stringify(afterSkills)}`,
|
||||
);
|
||||
assert(!afterSkills.includes("vpn"), `ghost skill vpn still in admin: ${JSON.stringify(afterSkills)}`);
|
||||
assert(
|
||||
afterMcps.includes("s2-keep-mcp") || afterMcps.includes("S2 Keep MCP"),
|
||||
`admin mcp missing keep: ${JSON.stringify(afterMcps)}`,
|
||||
);
|
||||
assert(!afterMcps.includes("context7"), `ghost mcp context7 still in admin: ${JSON.stringify(afterMcps)}`);
|
||||
console.log(` [admin] skills=${afterSkills.join(",")} mcp=${afterMcps.join(",")}`);
|
||||
|
||||
const effective = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||||
assert(effective.status === 200, `effective: ${effective.status}`);
|
||||
const effSkills = skillIds(effective.body.skills);
|
||||
const effMcps = mcpIds(effective.body.mcpServers);
|
||||
assert(!effSkills.includes("vpn"), `effective still has vpn ghost: ${JSON.stringify(effSkills)}`);
|
||||
assert(
|
||||
!effSkills.some((id) => id === "vpn" || id.toLowerCase() === "vpn"),
|
||||
`effective skills contain vpn: ${JSON.stringify(effSkills)}`,
|
||||
);
|
||||
assert(!effMcps.includes("context7"), `effective still has context7 ghost: ${JSON.stringify(effMcps)}`);
|
||||
assert(
|
||||
effSkills.some((id) => id.includes("S2 Keep") || id === "s2-keep-skill"),
|
||||
`effective missing keep skill: ${JSON.stringify(effSkills)}`,
|
||||
);
|
||||
console.log(` [effective] skills=${effSkills.join(",")} mcp=${effMcps.join(",")}`);
|
||||
|
||||
// Second put with same payload must remain ghost-free (tombstones sticky).
|
||||
const put2 = await fetchJson(`${BASE}/api/ai-admin/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ skills: keepSkill, mcpServers: keepMcp }),
|
||||
});
|
||||
assert(put2.status === 200 && put2.body.ok === true, `admin put2: ${put2.status}`);
|
||||
const effective2 = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||||
const effSkills2 = skillIds(effective2.body.skills);
|
||||
assert(!effSkills2.includes("vpn"), `ghost rehydrated after second put: ${JSON.stringify(effSkills2)}`);
|
||||
console.log(" [sticky] second put still tombstoned defaults");
|
||||
|
||||
console.log("\n✅ S2 skills/MCP delete no ghosts smoke passed\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S2 smoke failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
// S3: admin access-scopes SoT = directory_grants (full grant table), same as access-policy.
|
||||
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE = process.env.MNOTE_S3_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||||
const ACTOR_ID = process.env.MNOTE_S3_ACTOR_ID || "s3-grants-admin";
|
||||
const OTHER_ID = process.env.MNOTE_S3_OTHER_ID || "s3-grants-other-user";
|
||||
const AUTH = process.env.MNOTE_S3_AUTH || "Bearer s3-grants";
|
||||
const ROOT =
|
||||
process.env.MNOTE_S3_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-"));
|
||||
const OTHER_ROOT =
|
||||
process.env.MNOTE_S3_OTHER_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-other-"));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: "admin",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: `local-ws:${ACTOR_ID}:s3`,
|
||||
workspace_name: "S3 grants admin",
|
||||
root_uri: `file://${ROOT}`,
|
||||
root_path: ROOT,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s3_grants_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: OTHER_ID,
|
||||
email: `${OTHER_ID}@example.com`,
|
||||
username: OTHER_ID,
|
||||
display_name: OTHER_ID,
|
||||
role: "user",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: `local-ws:${OTHER_ID}:s3`,
|
||||
workspace_name: "S3 grants other",
|
||||
root_uri: `file://${OTHER_ROOT}`,
|
||||
root_path: OTHER_ROOT,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s3_grants_smoke_other",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
"S3 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
seed.status === 200 && seed.body.ok === true,
|
||||
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function rootUris(allowedRoots) {
|
||||
return (Array.isArray(allowedRoots) ? allowedRoots : [])
|
||||
.map((r) => String(r.rootUri || r.root_uri || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function userIds(allowedRoots) {
|
||||
return (Array.isArray(allowedRoots) ? allowedRoots : [])
|
||||
.map((r) => String(r.userId || r.user_id || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
fs.mkdirSync(OTHER_ROOT, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT, "s3.md"), "# S3 grants\n", "utf8");
|
||||
fs.writeFileSync(path.join(OTHER_ROOT, "other.md"), "# other\n", "utf8");
|
||||
|
||||
console.log(`\n🧪 S3 directory grants ≡ allowed roots (base=${BASE})\n`);
|
||||
|
||||
await seed();
|
||||
|
||||
const adminScopes = await fetchJson(`${BASE}/api/ai-admin/access-scopes`);
|
||||
assert(
|
||||
adminScopes.status === 200,
|
||||
`admin access-scopes: ${adminScopes.status} ${JSON.stringify(adminScopes.body)}`,
|
||||
);
|
||||
assert(
|
||||
String(adminScopes.body.sourceOfTruth || adminScopes.body.source_of_truth) === "directory_grants",
|
||||
`sourceOfTruth expected directory_grants, got ${adminScopes.body.sourceOfTruth}`,
|
||||
);
|
||||
const adminUris = rootUris(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots);
|
||||
const adminUsers = userIds(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots);
|
||||
console.log(` admin scopes: ${adminUris.length} roots, users=${[...new Set(adminUsers)].join(",")}`);
|
||||
|
||||
// Admin SoT must include both admin actor grant and other user's grant (full table).
|
||||
assert(
|
||||
adminUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`),
|
||||
`admin scopes missing admin root: ${JSON.stringify(adminUris)}`,
|
||||
);
|
||||
assert(
|
||||
adminUris.some((u) => u.includes(OTHER_ROOT) || u === `file://${OTHER_ROOT}`),
|
||||
`admin scopes missing OTHER user grant (must be full grant table, not actor-scoped): ${JSON.stringify(adminUris)}`,
|
||||
);
|
||||
assert(
|
||||
adminUsers.includes(OTHER_ID) || adminUsers.some((u) => u.includes("s3-grants-other")),
|
||||
`admin scopes should list other user_id: ${JSON.stringify(adminUsers)}`,
|
||||
);
|
||||
|
||||
// User-facing scopes for admin actor should be actor-scoped (own grants only).
|
||||
const userScopes = await fetchJson(`${BASE}/api/ai-settings/access-scopes`);
|
||||
assert(userScopes.status === 200, `user access-scopes: ${userScopes.status}`);
|
||||
assert(
|
||||
String(userScopes.body.sourceOfTruth || userScopes.body.source_of_truth) === "directory_grants",
|
||||
`user sourceOfTruth expected directory_grants`,
|
||||
);
|
||||
const userUris = rootUris(userScopes.body.allowedRoots || userScopes.body.allowed_roots);
|
||||
assert(
|
||||
userUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`),
|
||||
`user scopes missing own root`,
|
||||
);
|
||||
// Own actor scopes should not include unrelated other-root as "mine" identity —
|
||||
// but if system seeds extra grants for admin, still require admin endpoint ≥ user endpoint.
|
||||
assert(
|
||||
adminUris.length >= userUris.length,
|
||||
`admin full table (${adminUris.length}) should cover at least actor scopes (${userUris.length})`,
|
||||
);
|
||||
console.log(` user (actor) scopes: ${userUris.length}; admin full: ${adminUris.length}`);
|
||||
|
||||
// Effective also declares directory_grants SoT.
|
||||
const effective = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||||
assert(effective.status === 200, `effective: ${effective.status}`);
|
||||
assert(
|
||||
String(effective.body.sourceOfTruth || effective.body.source_of_truth) === "directory_grants",
|
||||
`effective sourceOfTruth: ${effective.body.sourceOfTruth}`,
|
||||
);
|
||||
console.log(" effective.sourceOfTruth=directory_grants");
|
||||
|
||||
console.log("\n✅ S3 directory grants ≡ allowed roots smoke passed\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S3 smoke failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
// S4: Pi abort once — API abort works; client reducer is idempotent (static + mock API).
|
||||
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE = process.env.MNOTE_S4_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||||
const ACTOR_ID = process.env.MNOTE_S4_ACTOR_ID || "s4-abort-admin";
|
||||
const AUTH = process.env.MNOTE_S4_AUTH || "Bearer s4-abort";
|
||||
const ROOT =
|
||||
process.env.MNOTE_S4_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s4-abort-"));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function seedWorkspace(rootUri, rootPath) {
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: "admin",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: `local-ws:${ACTOR_ID}:s4`,
|
||||
workspace_name: "S4 abort",
|
||||
root_uri: rootUri,
|
||||
root_path: rootPath,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s4_abort_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
"S4 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
seed.status === 200 && seed.body.ok === true,
|
||||
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function staticClientChecks() {
|
||||
const runtimePath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js",
|
||||
);
|
||||
const src = fs.readFileSync(runtimePath, "utf8");
|
||||
assert(
|
||||
src.includes("assistant_abort") && src.includes("STATE_ABORTED"),
|
||||
"client must handle assistant_abort → STATE_ABORTED",
|
||||
);
|
||||
assert(
|
||||
src.includes("abort is idempotent") ||
|
||||
(src.includes("runtimeStatus === STATE_ABORTED") && src.includes("assistant_abort")),
|
||||
"client reducer must short-circuit second assistant_abort",
|
||||
);
|
||||
// Optimistic single apply before fire-and-forget ABORT.
|
||||
assert(
|
||||
/function abortPrompt[\s\S]*?applyPiRunEvent\(\{\s*type:\s*'assistant_abort'/.test(src) ||
|
||||
/function abortPrompt[\s\S]*?applyPiRunEvent\(\{ type: 'assistant_abort'/.test(src),
|
||||
"abortPrompt must optimistically apply assistant_abort once",
|
||||
);
|
||||
assert(
|
||||
/function abortPrompt[\s\S]*?fetch\(API\.ABORT/.test(src) ||
|
||||
/function abortPrompt[\s\S]*?API\.ABORT/.test(src),
|
||||
"abortPrompt must still call ABORT API",
|
||||
);
|
||||
console.log(" [static] client abort once + idempotent reducer OK");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
staticClientChecks();
|
||||
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT, "s4.md"), "# S4 abort\n", "utf8");
|
||||
const rootUri = `file://${ROOT}`;
|
||||
|
||||
console.log(`\n🧪 S4 Pi abort idempotent (base=${BASE}, actor=${ACTOR_ID})\n`);
|
||||
|
||||
await seedWorkspace(rootUri, ROOT);
|
||||
|
||||
const status = await fetchJson(`${BASE}/api/page-ai/pi/status`);
|
||||
assert(status.status === 200 && status.body.ok === true, `status: ${status.status}`);
|
||||
if (status.body.runtimeMode && status.body.runtimeMode !== "mock") {
|
||||
console.log(` ⚠ runtimeMode=${status.body.runtimeMode} (prefer mock for this smoke)`);
|
||||
}
|
||||
|
||||
const sessionId = `pi_lab_s4_${Date.now()}`;
|
||||
const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
rootUri,
|
||||
workspaceId: `local-ws:${ACTOR_ID}:s4`,
|
||||
pagePath: "s4.md",
|
||||
pageTitle: "S4 abort",
|
||||
}),
|
||||
});
|
||||
assert(start.status === 200 && start.body.ok === true, `start: ${start.status} ${JSON.stringify(start.body)}`);
|
||||
const sid = (start.body.session && start.body.session.sessionId) || sessionId;
|
||||
console.log(` started ${sid}`);
|
||||
|
||||
// Send may stream; abort immediately after.
|
||||
const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId: sid,
|
||||
message: "S4 long answer please keep streaming",
|
||||
rootUri,
|
||||
}),
|
||||
});
|
||||
// Mock may accept or complete quickly — either is fine before abort.
|
||||
assert(
|
||||
send.status === 200 || send.status === 409 || send.status === 400,
|
||||
`send unexpected: ${send.status} ${JSON.stringify(send.body)}`,
|
||||
);
|
||||
console.log(` send status=${send.status}`);
|
||||
|
||||
const abort1 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId: sid }),
|
||||
});
|
||||
assert(
|
||||
abort1.status === 200 && abort1.body.ok === true && abort1.body.aborted === true,
|
||||
`abort1: ${abort1.status} ${JSON.stringify(abort1.body)}`,
|
||||
);
|
||||
assert(abort1.body.stopReason === "aborted" || abort1.body.aborted === true, "abort1 stopReason");
|
||||
console.log(" abort1 ok");
|
||||
|
||||
// Second abort must still succeed (idempotent server) without double-error.
|
||||
const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId: sid }),
|
||||
});
|
||||
assert(
|
||||
abort2.status === 200 && abort2.body.ok === true && abort2.body.aborted === true,
|
||||
`abort2 must be idempotent: ${abort2.status} ${JSON.stringify(abort2.body)}`,
|
||||
);
|
||||
console.log(" abort2 idempotent ok");
|
||||
|
||||
console.log("\n✅ S4 Pi abort idempotent smoke passed\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S4 smoke failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
// S5: RAG status health + summary shape + FileTree tooltip static contract.
|
||||
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 (LightRAG may be down — status still ok:true).
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE = process.env.MNOTE_S5_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||||
const ACTOR_ID = process.env.MNOTE_S5_ACTOR_ID || "s5-rag-admin";
|
||||
const AUTH = process.env.MNOTE_S5_AUTH || "Bearer s5-rag";
|
||||
const ROOT =
|
||||
process.env.MNOTE_S5_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s5-rag-"));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function seedWorkspace(rootUri, rootPath) {
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: "admin",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: `local-ws:${ACTOR_ID}:s5`,
|
||||
workspace_name: "S5 RAG",
|
||||
root_uri: rootUri,
|
||||
root_path: rootPath,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s5_rag_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
"S5 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
seed.status === 200 && seed.body.ok === true,
|
||||
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function staticChecks() {
|
||||
const liveApply = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js",
|
||||
);
|
||||
const aiAdmin = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"rust/crates/mnote-web/src/ssr/pages/ai_admin.rs",
|
||||
);
|
||||
const liveSrc = fs.readFileSync(liveApply, "utf8");
|
||||
const adminSrc = fs.readFileSync(aiAdmin, "utf8");
|
||||
|
||||
assert(liveSrc.includes("indexStatusLabel"), "FileTree must compute indexStatusLabel");
|
||||
assert(
|
||||
liveSrc.includes("已索引") && liveSrc.includes("索引中") && liveSrc.includes("索引失败"),
|
||||
"FileTree tooltip labels for indexed/indexing/failed",
|
||||
);
|
||||
assert(
|
||||
liveSrc.includes("linkTitle") &&
|
||||
(liveSrc.includes("title=\"") || liveSrc.includes("title='") || liveSrc.includes("title=\"") || liveSrc.includes("title=") || liveSrc.includes("title=")),
|
||||
"FileTree rows should set title/linkTitle for tooltip",
|
||||
);
|
||||
// Prefer title attribute wiring for tooltip.
|
||||
assert(
|
||||
/linkTitle[\s\S]{0,400}title=/.test(liveSrc) ||
|
||||
/title=[\s\S]{0,200}linkTitle/.test(liveSrc) ||
|
||||
liveSrc.includes("escapeHtml(linkTitle)"),
|
||||
"linkTitle must be wired into element title attribute",
|
||||
);
|
||||
|
||||
assert(
|
||||
adminSrc.includes("/api/knowledge-rag/status"),
|
||||
"AI admin knowledge panel must fetch /api/knowledge-rag/status",
|
||||
);
|
||||
assert(
|
||||
adminSrc.includes("data-ai-admin-knowledge-summary") ||
|
||||
adminSrc.includes("knowledge-summary"),
|
||||
"AI admin must render knowledge summary region",
|
||||
);
|
||||
console.log(" [static] FileTree tooltip + admin knowledge summary OK");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
staticChecks();
|
||||
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT, "s5.md"), "# S5 RAG\n", "utf8");
|
||||
const rootUri = `file://${ROOT}`;
|
||||
const workspaceId = `local-ws:${ACTOR_ID}:s5`;
|
||||
|
||||
console.log(`\n🧪 S5 RAG health + summary (base=${BASE})\n`);
|
||||
|
||||
await seedWorkspace(rootUri, ROOT);
|
||||
|
||||
const bare = await fetchJson(`${BASE}/api/knowledge-rag/status`);
|
||||
assert(bare.status === 200 && bare.body.ok === true, `status bare: ${bare.status} ${JSON.stringify(bare.body).slice(0, 300)}`);
|
||||
assert(
|
||||
String(bare.body.schema) === "mnote.knowledge_rag.provider_status.v1",
|
||||
`schema: ${bare.body.schema}`,
|
||||
);
|
||||
assert(bare.body.health !== undefined, "status must include health");
|
||||
assert(bare.body.pipeline !== undefined || bare.body.documents !== undefined, "status must include pipeline or documents");
|
||||
console.log(
|
||||
` bare: provider=${bare.body.provider} health.ok=${bare.body.health && bare.body.health.ok}`,
|
||||
);
|
||||
|
||||
const scoped = await fetchJson(
|
||||
`${BASE}/api/knowledge-rag/status?workspaceId=${encodeURIComponent(workspaceId)}&rootUri=${encodeURIComponent(rootUri)}`,
|
||||
);
|
||||
assert(
|
||||
scoped.status === 200 && scoped.body.ok === true,
|
||||
`status scoped: ${scoped.status} ${JSON.stringify(scoped.body).slice(0, 300)}`,
|
||||
);
|
||||
// Scoped should attach registry when root is readable.
|
||||
assert(
|
||||
scoped.body.registry !== undefined || scoped.body.registryDiagnostics !== undefined,
|
||||
"scoped status should include registry or diagnostics",
|
||||
);
|
||||
console.log(
|
||||
` scoped: registry=${scoped.body.registry ? "yes" : "no"} knowledgeBases=${scoped.body.knowledgeBases ? "yes" : "no"}`,
|
||||
);
|
||||
|
||||
// Admin UI page should mention knowledge status (SSR shell).
|
||||
const adminPage = await fetch(`${BASE}/ai-admin`, {
|
||||
headers: {
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
// May 200 or redirect depending on auth shell — only soft-check when 200.
|
||||
if (adminPage.status === 200) {
|
||||
const html = await adminPage.text();
|
||||
assert(
|
||||
html.includes("knowledge-rag") ||
|
||||
html.includes("data-ai-admin-knowledge") ||
|
||||
html.includes("知识"),
|
||||
"ai-admin HTML should include knowledge panel markers",
|
||||
);
|
||||
console.log(" ai-admin HTML knowledge panel markers present");
|
||||
} else {
|
||||
console.log(` ai-admin HTTP ${adminPage.status} (skip HTML; API shape verified)`);
|
||||
}
|
||||
|
||||
console.log("\n✅ S5 RAG health + summary + FileTree smoke passed\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S5 smoke failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
// S6: Pi session resume/history from control-plane (seedAiRuntime + hydrate).
|
||||
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE = process.env.MNOTE_S6_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||||
const ACTOR_ID = process.env.MNOTE_S6_ACTOR_ID || "s6-hydrate-admin";
|
||||
const AUTH = process.env.MNOTE_S6_AUTH || "Bearer s6-hydrate";
|
||||
const ROOT =
|
||||
process.env.MNOTE_S6_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s6-hydrate-"));
|
||||
const STAMP = Date.now();
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
const pagePath = `s6-hydrate-${STAMP}.md`;
|
||||
fs.writeFileSync(path.join(ROOT, pagePath), "# S6 hydrate\n", "utf8");
|
||||
const rootUri = `file://${ROOT}`;
|
||||
const workspaceId = `local-ws:${ACTOR_ID}:s6`;
|
||||
const sessionId = `pi_lab_s6_${STAMP}`;
|
||||
const runId = `pi_run_${sessionId}`;
|
||||
const piSessionDir = path.join(ROOT, "pi-session");
|
||||
const piSessionFile = path.join(piSessionDir, `${STAMP}_session.jsonl`);
|
||||
fs.mkdirSync(piSessionDir, { recursive: true });
|
||||
const historyText = `S6_HYDRATE_REPLY_${STAMP}`;
|
||||
fs.writeFileSync(
|
||||
piSessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
id: "u1",
|
||||
type: "message",
|
||||
message: { role: "user", content: "hydrate me" },
|
||||
seq: 1,
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "a1",
|
||||
parentId: "u1",
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: historyText }],
|
||||
},
|
||||
seq: 2,
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
console.log(`\n🧪 S6 Pi resume/history hydrate (base=${BASE}, session=${sessionId})\n`);
|
||||
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: "admin",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: workspaceId,
|
||||
workspace_name: "S6 hydrate",
|
||||
root_uri: rootUri,
|
||||
root_path: ROOT,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s6_hydrate_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
{
|
||||
kind: "seedAiRuntime",
|
||||
user_id: ACTOR_ID,
|
||||
workspace_id: workspaceId,
|
||||
document_id: pagePath,
|
||||
session_id: sessionId,
|
||||
run_id: runId,
|
||||
title: "S6 hydrate session",
|
||||
profile: "pi_lab",
|
||||
acp_runtime: "pi",
|
||||
status: "runtime_running",
|
||||
runtime_json: {
|
||||
runtimeMode: "mock",
|
||||
rootUri,
|
||||
workspaceId,
|
||||
pagePath,
|
||||
pageTitle: "S6 hydrate",
|
||||
modelProvider: "omniroute",
|
||||
modelId: "pi-fast",
|
||||
thinkingLevel: "medium",
|
||||
piSessionDir,
|
||||
piSessionFile,
|
||||
messageCount: 2,
|
||||
},
|
||||
payload_json: { message: "s6 hydrate" },
|
||||
events: [
|
||||
{
|
||||
eventType: "user_prompt",
|
||||
payloadJson: { message: "hydrate me" },
|
||||
},
|
||||
{
|
||||
eventType: "session_seeded",
|
||||
payloadJson: { source: "s6_smoke" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
"S6 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
seed.status === 200 && seed.body.ok === true,
|
||||
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||||
);
|
||||
console.log(" seeded workspace + ai runtime");
|
||||
|
||||
// list_sessions should surface the seeded Pi Lab run from control-plane.
|
||||
const listed = await fetchJson(
|
||||
`${BASE}/api/page-ai/pi/sessions?workspaceId=${encodeURIComponent(workspaceId)}&limit=50`,
|
||||
);
|
||||
assert(listed.status === 200 && listed.body.ok === true, `list: ${listed.status} ${JSON.stringify(listed.body)}`);
|
||||
assert(
|
||||
String(listed.body.schema) === "mnote.page_ai_pi.list_sessions.v1",
|
||||
`list schema: ${listed.body.schema}`,
|
||||
);
|
||||
const sessions = Array.isArray(listed.body.sessions) ? listed.body.sessions : [];
|
||||
const found = sessions.find((s) => String(s.sessionId) === sessionId);
|
||||
assert(found, `list_sessions missing ${sessionId}: ${JSON.stringify(sessions).slice(0, 500)}`);
|
||||
console.log(` list_sessions includes ${sessionId} status=${found.status}`);
|
||||
|
||||
// get_session_history should return the control-plane run (even without hot cache).
|
||||
const history = await fetchJson(`${BASE}/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}`);
|
||||
assert(
|
||||
history.status === 200 && history.body.ok !== false,
|
||||
`history: ${history.status} ${JSON.stringify(history.body).slice(0, 400)}`,
|
||||
);
|
||||
// Accept either structured history messages or run metadata with runtime json.
|
||||
const bodyText = JSON.stringify(history.body);
|
||||
assert(
|
||||
bodyText.includes(sessionId) ||
|
||||
bodyText.includes(runId) ||
|
||||
bodyText.includes("hydrate") ||
|
||||
bodyText.includes(historyText) ||
|
||||
bodyText.includes("pi_lab"),
|
||||
`history payload should identify session: ${bodyText.slice(0, 500)}`,
|
||||
);
|
||||
console.log(" get_session_history ok");
|
||||
|
||||
// Cold path: abort uses get_session_for_context → hydrate_session_from_control_plane.
|
||||
// Session is NOT in process hot cache after seed-only; hydrate must restore it.
|
||||
const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
assert(
|
||||
abort.status === 200 && abort.body.ok === true && abort.body.aborted === true,
|
||||
`hydrate via abort failed (session not restored from control-plane): ${abort.status} ${JSON.stringify(abort.body)}`,
|
||||
);
|
||||
console.log(" hydrate via abort: session restored from control-plane");
|
||||
|
||||
// Second operation after hydrate still works (hot cache now warm).
|
||||
const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
assert(
|
||||
abort2.status === 200 && abort2.body.ok === true,
|
||||
`warm abort: ${abort2.status} ${JSON.stringify(abort2.body)}`,
|
||||
);
|
||||
console.log(" warm-cache abort ok");
|
||||
|
||||
console.log("\n✅ S6 Pi resume/history hydrate smoke passed\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S6 smoke failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env node
|
||||
// S7: agent target package on send + dirty write conflict gate (client static + API accept).
|
||||
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const BASE = process.env.MNOTE_S7_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||||
const ACTOR_ID = process.env.MNOTE_S7_ACTOR_ID || "s7-target-admin";
|
||||
const AUTH = process.env.MNOTE_S7_AUTH || "Bearer s7-target";
|
||||
const ROOT =
|
||||
process.env.MNOTE_S7_ROOT ||
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s7-target-"));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: AUTH,
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const text = await res.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
function staticChecks() {
|
||||
const piLab = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js",
|
||||
);
|
||||
const src = fs.readFileSync(piLab, "utf8");
|
||||
assert(src.includes("mnote.agent_target_package.v1"), "must build agent target package v1");
|
||||
assert(
|
||||
src.includes("buildPiLabAgentTargetPackage") || src.includes("targetPackage"),
|
||||
"must attach targetPackage on send",
|
||||
);
|
||||
assert(
|
||||
src.includes("assertPiLabTargetWritable") || src.includes("page_ai_target_buffer_not_writable"),
|
||||
"must gate dirty buffer before send",
|
||||
);
|
||||
assert(
|
||||
src.includes("writeRequiresCleanBuffer") || src.includes("fail_on_dirty_or_stale"),
|
||||
"policy must require clean buffer / fail on dirty",
|
||||
);
|
||||
assert(
|
||||
src.includes("targetPackage: targetPackage") || src.includes("targetPackage:targetPackage"),
|
||||
"send body must include targetPackage",
|
||||
);
|
||||
|
||||
const runtimeRs = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs",
|
||||
);
|
||||
const rs = fs.readFileSync(runtimeRs, "utf8");
|
||||
assert(
|
||||
rs.includes("target_package") && rs.includes("PiLabSendRequest"),
|
||||
"server PiLabSendRequest must accept target_package",
|
||||
);
|
||||
console.log(" [static] client target package + dirty gate + server accept field OK");
|
||||
}
|
||||
|
||||
async function seedWorkspace(rootUri, rootPath) {
|
||||
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
seeds: [
|
||||
{
|
||||
kind: "setupWorkspace",
|
||||
user_id: ACTOR_ID,
|
||||
email: `${ACTOR_ID}@example.com`,
|
||||
username: ACTOR_ID,
|
||||
display_name: ACTOR_ID,
|
||||
role: "admin",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
workspace_id: `local-ws:${ACTOR_ID}:s7`,
|
||||
workspace_name: "S7 target",
|
||||
root_uri: rootUri,
|
||||
root_path: rootPath,
|
||||
source_kind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grant_source: "s7_target_smoke",
|
||||
grant_created_by: ACTOR_ID,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||||
throw new Error(
|
||||
"S7 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
seed.status === 200 && seed.body.ok === true,
|
||||
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
staticChecks();
|
||||
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
const pagePath = "s7.md";
|
||||
fs.writeFileSync(path.join(ROOT, pagePath), "# S7 target package\n", "utf8");
|
||||
const rootUri = `file://${ROOT}`;
|
||||
const workspaceId = `local-ws:${ACTOR_ID}:s7`;
|
||||
const documentId = `local-doc:${workspaceId}:${pagePath}`;
|
||||
|
||||
console.log(`\n🧪 S7 agent target package + dirty guard (base=${BASE})\n`);
|
||||
|
||||
await seedWorkspace(rootUri, ROOT);
|
||||
|
||||
const sessionId = `pi_lab_s7_${Date.now()}`;
|
||||
const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
rootUri,
|
||||
workspaceId,
|
||||
pagePath,
|
||||
pageTitle: "S7 target",
|
||||
}),
|
||||
});
|
||||
assert(start.status === 200 && start.body.ok === true, `start: ${start.status} ${JSON.stringify(start.body)}`);
|
||||
const sid = (start.body.session && start.body.session.sessionId) || sessionId;
|
||||
|
||||
const targetPackage = {
|
||||
schema: "mnote.agent_target_package.v1",
|
||||
source: "s7_smoke",
|
||||
frozenAt: Date.now(),
|
||||
primaryTargetId: documentId,
|
||||
workspaceId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
resourceKind: "page",
|
||||
workspacePath: {
|
||||
schema: "mnote.workspace_path.v1",
|
||||
workspaceId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
relativePath: pagePath,
|
||||
documentId,
|
||||
resourceKind: "page",
|
||||
},
|
||||
currentFile: {
|
||||
rootUri,
|
||||
relativePath: pagePath,
|
||||
documentId,
|
||||
resourceKind: "page",
|
||||
},
|
||||
allowedFiles: [pagePath],
|
||||
targets: [
|
||||
{
|
||||
targetId: documentId,
|
||||
documentId,
|
||||
workspaceId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
relativePath: pagePath,
|
||||
resourceKind: "page",
|
||||
dirtyState: "clean",
|
||||
policy: {
|
||||
permission: "read_write",
|
||||
writeRequiresCleanBuffer: true,
|
||||
conflictPolicy: "fail_on_dirty_or_stale",
|
||||
},
|
||||
},
|
||||
],
|
||||
policy: {
|
||||
writeRequiresExplicitTarget: true,
|
||||
allowedFilesSource: "selected_page_ai_target",
|
||||
conflictPolicy: "fail_on_dirty_or_stale",
|
||||
},
|
||||
};
|
||||
|
||||
// Server must accept targetPackage on send (deserialize, not 422).
|
||||
const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId: sid,
|
||||
message: "S7 with target package",
|
||||
rootUri,
|
||||
workspaceId,
|
||||
pagePath,
|
||||
pageTitle: "S7 target",
|
||||
targetPackage,
|
||||
}),
|
||||
});
|
||||
assert(
|
||||
send.status === 200 && send.body.ok !== false,
|
||||
`send with targetPackage: ${send.status} ${JSON.stringify(send.body)}`,
|
||||
);
|
||||
assert(
|
||||
send.body.accepted === true || send.body.sessionId || send.body.ok === true,
|
||||
`send should accept prompt: ${JSON.stringify(send.body)}`,
|
||||
);
|
||||
console.log(" send accepts targetPackage");
|
||||
|
||||
// buffer-state query shape (may 404 if no buffer opened — still valid API).
|
||||
const bufferQs = new URLSearchParams({
|
||||
documentId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
workspaceId,
|
||||
relativePath: pagePath,
|
||||
});
|
||||
const buffer = await fetchJson(`${BASE}/api/documents/buffer-state?${bufferQs.toString()}`);
|
||||
// Not found is OK for never-opened buffer; found must include dirtyState when present.
|
||||
if (buffer.status === 200 && buffer.body.ok === true) {
|
||||
const result = buffer.body.result || buffer.body;
|
||||
assert(
|
||||
result.dirtyState !== undefined || result.dirty_state !== undefined || result.status,
|
||||
`buffer-state payload missing dirty fields: ${JSON.stringify(buffer.body)}`,
|
||||
);
|
||||
console.log(` buffer-state present dirtyState=${result.dirtyState || result.dirty_state}`);
|
||||
} else {
|
||||
console.log(
|
||||
` buffer-state ${buffer.status} (no open buffer — client gate uses snapshot dirty; OK)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Client error code contract for UI.
|
||||
const piLab = fs.readFileSync(
|
||||
path.join(__dirname, "..", "rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
piLab.includes("page_ai_target_buffer_not_writable"),
|
||||
"UI must surface page_ai_target_buffer_not_writable",
|
||||
);
|
||||
console.log(" dirty conflict code page_ai_target_buffer_not_writable present");
|
||||
|
||||
console.log("\n✅ S7 agent target package + dirty guard smoke passed\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ S7 smoke failed:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { BASE_URL, UI_TIMEOUT_MS, ensureAuthenticated, assert } = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-vault-single-folder-"));
|
||||
const ROOT_URI = `file://${ROOT}`;
|
||||
|
||||
function authCookieHeader() {
|
||||
const output = execFileSync(process.execPath, [path.join(__dirname, "mnote-vault-cli.js"), "auth-e2e"], {
|
||||
cwd: path.resolve(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const match = output.match(/^export MNOTE_COOKIE=(.+)$/m);
|
||||
if (!match) throw new Error("auth-e2e 未返回 MNOTE_COOKIE");
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest() {
|
||||
const metadataDir = path.join(ROOT, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: "local-ws:mnote-e2e:vault-single-folder",
|
||||
ownerId: "mnote-e2e",
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "vault"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function requestJson(context, method, urlPath, data) {
|
||||
const response = await context.fetch(`${BASE_URL}${urlPath}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
data,
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert(response.ok(), `${urlPath} 请求失败: ${response.status()} ${JSON.stringify(payload)}`);
|
||||
return payload.result || payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
writeWorkspaceManifest();
|
||||
const cookieHeader = authCookieHeader();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1180, height: 900 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: { cookie: cookieHeader },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
await requestJson(context.request, "POST", "/api/vault/items", {
|
||||
rootUri: ROOT_URI,
|
||||
title: "单账号服务",
|
||||
username: "solo-user",
|
||||
password: process.env.MNOTE_VAULT_SMOKE_PASSWORD || "pw[A]",
|
||||
folderPath: "个人/密码/单账号服务",
|
||||
tags: ["temporary-test-tag"],
|
||||
notesMarkdown: "## 临时说明\n\n仅用于 smoke。",
|
||||
});
|
||||
await page.goto(`${BASE_URL}/vault?sourceKind=local_folder&rootUri=${encodeURIComponent(ROOT_URI)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-testid="mnote-vault-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="vault-list-item"]', { hasText: "单账号服务" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const folderText = await page.locator('[data-testid="vault-list"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!folderText.includes("单账号服务\n单账号服务"), "单账号文件夹不应再渲染成同名可折叠父节点 + 子条目");
|
||||
const singleFolderHeaders = await page.locator('[data-vault-toggle-folder]', { hasText: "单账号服务" }).count();
|
||||
assert(singleFolderHeaders === 0, `单账号文件夹不应出现可折叠标题,实际 ${singleFolderHeaders}`);
|
||||
console.log(JSON.stringify({ ok: true, rootUri: ROOT_URI }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "acp-multi-session-stability-smoke");
|
||||
const PROFILE = process.env.MNOTE_ACP_STABILITY_PROFILE || "reasonix";
|
||||
const RUNTIME = process.env.MNOTE_ACP_STABILITY_RUNTIME || "";
|
||||
const RUN_COUNT = Math.max(1, Number(process.env.MNOTE_ACP_STABILITY_RUN_COUNT || 3));
|
||||
const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_ACP_STABILITY_STREAM_TIMEOUT_MS || 60_000);
|
||||
const CANCEL_INDEX = Math.min(Math.max(0, Number(process.env.MNOTE_ACP_STABILITY_CANCEL_INDEX || 1)), RUN_COUNT - 1);
|
||||
|
||||
function nowSuffix() {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createHermesSession(request, target, suffix, index) {
|
||||
return requestJson(request, "/api/hermes/client/sessions", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "acp-smoke-user" },
|
||||
data: {
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
title: `ACP 稳定性 ${suffix} #${index + 1}`,
|
||||
profile: PROFILE,
|
||||
traceId: `trace_acp_session_${suffix}_${index}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createHermesRun(request, target, sessionId, suffix, index) {
|
||||
const payload = {
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
sessionId,
|
||||
profile: PROFILE,
|
||||
message:
|
||||
index === CANCEL_INDEX
|
||||
? `请用中文逐条列出 1 到 200 的检查项,每条都包含 ACP 多会话 smoke ${suffix},不要修改文件。`
|
||||
: `请用一句中文回复:ACP 多会话 smoke ${suffix} 第 ${index + 1} 条。不要修改文件。`,
|
||||
contextScope: "summary",
|
||||
traceId: `trace_acp_run_${suffix}_${index}`,
|
||||
actorId: "acp-smoke-user",
|
||||
};
|
||||
if (RUNTIME) {
|
||||
payload.acpRuntime = RUNTIME;
|
||||
}
|
||||
return requestJson(request, "/api/hermes/client/runs", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "acp-smoke-user" },
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function abortRun(request, runId) {
|
||||
return requestJson(request, `/api/hermes/client/runs/${encodeURIComponent(runId)}/abort`, {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "acp-smoke-user" },
|
||||
data: { reason: "task488_acp_stability_smoke_cancel" },
|
||||
});
|
||||
}
|
||||
|
||||
function parseSseChunk(text, events) {
|
||||
for (const frame of text.split(/\n\n+/)) {
|
||||
const lines = frame.split(/\n/).filter(Boolean);
|
||||
if (lines.length === 0) continue;
|
||||
let event = "message";
|
||||
const dataLines = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice("event:".length).trim() || "message";
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
if (dataLines.length === 0) continue;
|
||||
const dataText = dataLines.join("\n");
|
||||
let data = dataText;
|
||||
try {
|
||||
data = JSON.parse(dataText);
|
||||
} catch {
|
||||
// 保留原始文本,便于排障。
|
||||
}
|
||||
events.push({ event, data });
|
||||
}
|
||||
}
|
||||
|
||||
async function cookieHeaderForContext(context) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
||||
}
|
||||
|
||||
async function streamRunEvents(runId, label, cookieHeader) {
|
||||
const events = [];
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/hermes/client/events/${encodeURIComponent(runId)}`, {
|
||||
headers: {
|
||||
"x-mnote-actor-id": "acp-smoke-user",
|
||||
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${label} events 请求失败: ${response.status} ${response.statusText} ${text.slice(0, 1200)}`);
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let raw = "";
|
||||
let pending = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
raw += chunk;
|
||||
pending += chunk;
|
||||
const frames = pending.split(/\n\n+/);
|
||||
pending = frames.pop() || "";
|
||||
if (frames.length > 0) {
|
||||
parseSseChunk(frames.join("\n\n"), events);
|
||||
}
|
||||
if (terminalEvent(events)) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const rest = decoder.decode();
|
||||
if (rest) {
|
||||
raw += rest;
|
||||
pending += rest;
|
||||
}
|
||||
if (pending.trim()) {
|
||||
parseSseChunk(pending, events);
|
||||
}
|
||||
if (!terminalEvent(events)) {
|
||||
throw new Error(`${label} events 未收到 terminal event`);
|
||||
}
|
||||
return { runId, status: response.status, events, raw };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
function terminalEvent(events) {
|
||||
return events.find((entry) => entry.event === "run.completed" || entry.event === "run.failed" || entry.event === "run.aborted");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = nowSuffix();
|
||||
const title = `TEST-ACP-STABILITY-${suffix}`;
|
||||
const createdIds = [];
|
||||
const evidence = {
|
||||
ok: false,
|
||||
baseUrl: BASE_URL,
|
||||
profile: PROFILE,
|
||||
runtime: RUNTIME || null,
|
||||
runCount: RUN_COUNT,
|
||||
cancelIndex: CANCEL_INDEX,
|
||||
title,
|
||||
sessions: [],
|
||||
runs: [],
|
||||
streams: [],
|
||||
};
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
evidence.documentId = target.documentId;
|
||||
evidence.workspaceId = target.workspaceId;
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const sessions = [];
|
||||
for (let index = 0; index < RUN_COUNT; index += 1) {
|
||||
const session = await createHermesSession(context.request, target, suffix, index);
|
||||
assert(session.sessionId, `session ${index + 1} 缺少 sessionId`);
|
||||
sessions.push(session);
|
||||
evidence.sessions.push({
|
||||
index,
|
||||
sessionId: session.sessionId,
|
||||
persistence: session.persistence || null,
|
||||
profile: session.profile || PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
const runs = await Promise.all(
|
||||
sessions.map((session, index) => createHermesRun(context.request, target, session.sessionId, suffix, index)),
|
||||
);
|
||||
for (let index = 0; index < runs.length; index += 1) {
|
||||
assert(runs[index].runId, `run ${index + 1} 缺少 runId`);
|
||||
evidence.runs.push({
|
||||
index,
|
||||
runId: runs[index].runId,
|
||||
sessionId: runs[index].sessionId,
|
||||
persistence: runs[index].persistence || null,
|
||||
runtime: runs[index].runtime || null,
|
||||
});
|
||||
}
|
||||
|
||||
const cookieHeader = await cookieHeaderForContext(context);
|
||||
const streamPromises = runs.map((run, index) => streamRunEvents(run.runId, `run ${index + 1}`, cookieHeader));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const abortPayload = await abortRun(context.request, runs[CANCEL_INDEX].runId);
|
||||
evidence.abort = {
|
||||
runId: runs[CANCEL_INDEX].runId,
|
||||
status: abortPayload.status,
|
||||
ok: abortPayload.ok,
|
||||
events: abortPayload.events || [],
|
||||
};
|
||||
|
||||
const streamResults = await Promise.allSettled(streamPromises);
|
||||
const rejectedStreams = streamResults
|
||||
.map((result, index) => ({ result, index }))
|
||||
.filter(({ result }) => result.status === "rejected");
|
||||
if (rejectedStreams.length > 0) {
|
||||
evidence.streamErrors = rejectedStreams.map(({ result, index }) => ({
|
||||
index,
|
||||
runId: runs[index].runId,
|
||||
message: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
stack: result.reason instanceof Error ? result.reason.stack : null,
|
||||
}));
|
||||
throw new Error(`ACP SSE 读取失败: ${evidence.streamErrors.map((item) => `run ${item.index + 1}: ${item.message}`).join("; ")}`);
|
||||
}
|
||||
const streams = streamResults.map((result) => result.value);
|
||||
for (let index = 0; index < streams.length; index += 1) {
|
||||
const stream = streams[index];
|
||||
const terminal = terminalEvent(stream.events);
|
||||
evidence.streams.push({
|
||||
index,
|
||||
runId: stream.runId,
|
||||
eventCount: stream.events.length,
|
||||
eventNames: stream.events.map((entry) => entry.event),
|
||||
terminalEvent: terminal ? terminal.event : null,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(OUT_DIR, `events-${index + 1}-${stream.runId}.json`),
|
||||
JSON.stringify(stream.events, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
assert(terminal, `run ${index + 1} 必须收到 run.completed、run.failed 或 run.aborted`);
|
||||
if (index === CANCEL_INDEX) {
|
||||
assert.equal(terminal.event, "run.aborted", "被 abort 的 run 必须以 run.aborted 结束");
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(new Set(evidence.sessions.map((item) => item.sessionId)).size, RUN_COUNT, "sessionId 必须互相隔离");
|
||||
assert.equal(new Set(evidence.runs.map((item) => item.runId)).size, RUN_COUNT, "runId 必须互相隔离");
|
||||
assert(evidence.abort.ok === true, "abort API 必须返回 ok=true");
|
||||
evidence.ok = true;
|
||||
await fs.writeFile(path.join(OUT_DIR, "result.json"), JSON.stringify(evidence, null, 2), "utf8");
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true }).catch(() => undefined);
|
||||
await fs
|
||||
.writeFile(
|
||||
path.join(OUT_DIR, "error.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
)
|
||||
.catch(() => undefined);
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -282,6 +282,11 @@ async function main() {
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert(response.status() === 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
const editModeToggle = page.locator('[data-document-edit-mode-toggle]').first();
|
||||
if (await editModeToggle.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await editModeToggle.click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
const editor = await waitForRuntimeIsland(page, UI_TIMEOUT_MS);
|
||||
|
||||
const moreButton = page.locator('[data-testid="wolai-page-settings-trigger"]').first();
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task544-weknora-provider-bridge-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function assertNotContains(content, needle, label) {
|
||||
assert(!content.includes(needle), `${label} 不应包含: ${needle}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
const route = read("rust/crates/mnote-web/src/routes/knowledge_rag.rs");
|
||||
const hermesKnowledge = read("rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs");
|
||||
const manifest = read("rust/crates/mnote-web/src/hermes_tools/manifest.rs");
|
||||
const toolRoute = read("rust/crates/mnote-web/src/routes/hermes_tools.rs");
|
||||
const ui = read("rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js");
|
||||
|
||||
assertContains(route, 'const DEFAULT_KNOWLEDGE_PROVIDER: &str = "weknora";', "provider boundary");
|
||||
assertContains(route, 'const LEGACY_KNOWLEDGE_PROVIDER: &str = "lightrag_legacy";', "legacy provider boundary");
|
||||
assertContains(route, '"/knowledge-search"', "WeKnora endpoint");
|
||||
assertContains(route, '"knowledge_base_ids"', "WeKnora request shape");
|
||||
assertContains(route, '"knowledge_ids"', "WeKnora request shape");
|
||||
assertContains(route, '"match_count"', "WeKnora request shape");
|
||||
assertContains(route, '"providerConfig": knowledge_provider_config()', "status provider config");
|
||||
assertContains(route, '"health": weknora_health(&context).await', "WeKnora health");
|
||||
assertContains(route, '"registry": registry', "status registry");
|
||||
assertContains(route, '"providerIds"', "WeKnora reference mapping");
|
||||
assertContains(route, '"providerFilenameIsNotLocalPath": true', "WeKnora locator diagnostics");
|
||||
assertContains(route, '"locatorDegraded": locator_degraded', "WeKnora degraded locator");
|
||||
assertContains(route, '"reference": chunk', "WeKnora raw reference retention");
|
||||
assertContains(route, 'Value::Null', "WeKnora filePath null guard");
|
||||
assertContains(route, '"chunkMetadata"', "WeKnora metadata retention");
|
||||
assertContains(route, '"imageInfo"', "WeKnora image info retention");
|
||||
assertContains(route, '"parentChunk"', "WeKnora parent chunk retention");
|
||||
assertContains(route, '"subChunks"', "WeKnora sub chunk retention");
|
||||
assertContains(route, "ingest_weknora_registry_only", "WeKnora ingest branch");
|
||||
assertContains(route, "upload_weknora_file", "WeKnora provider upload");
|
||||
assertContains(route, '/knowledge-bases/{}/knowledge/file', "WeKnora file ingest endpoint");
|
||||
assertContains(route, 'reqwest::multipart::Form::new()', "WeKnora multipart upload");
|
||||
assertContains(route, '"fileName"', "WeKnora preserves root relative path");
|
||||
assertContains(route, '"mnote_source_root_relative_path"', "WeKnora source metadata");
|
||||
assertContains(route, '"scanSkipped": false', "WeKnora ingest uses provider upload");
|
||||
assertContains(route, '"scanSkipReason": "weknora_provider_direct_upload"', "WeKnora ingest no LightRAG scan");
|
||||
assertContains(route, '"pending_provider_ingest_missing_kb"', "WeKnora ingest missing kb pending");
|
||||
assertContains(route, '"pending_provider_ingest"', "WeKnora ingest provider pending");
|
||||
assertContains(route, '"registry_only"', "WeKnora ingest registry mapping");
|
||||
assertContains(route, '"provider_mapped"', "WeKnora provider id mapping");
|
||||
assertContains(route, "delete_weknora_registry_source", "WeKnora delete branch");
|
||||
assertContains(route, '"providerDeleteAttempted": false', "WeKnora delete no provider call");
|
||||
assertContains(route, '"localFileDeleted": false', "WeKnora delete keeps local file");
|
||||
assertContains(route, '"pending_provider_delete"', "WeKnora delete pending provider marker");
|
||||
assertContains(route, "weknora_section_context_payload", "WeKnora section-context branch");
|
||||
assertContains(route, '"sidecarRead": false', "WeKnora section-context no LightRAG sidecar");
|
||||
assertContains(route, '"degradedReason": "weknora_local_source_context_without_provider_chunks"', "WeKnora section-context degraded local fallback");
|
||||
assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return ingest_weknora_registry_only', "WeKnora ingest bypasses LightRAG staging");
|
||||
assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return delete_weknora_registry_source', "WeKnora delete bypasses LightRAG delete");
|
||||
assertContains(route, "let payload = weknora_section_context_payload", "WeKnora section-context bypasses sidecar");
|
||||
|
||||
for (const toolName of [
|
||||
"mnote.weknora.search",
|
||||
"mnote.weknora.list_sources",
|
||||
"mnote.weknora.get_source_status",
|
||||
"mnote.weknora.open_reference",
|
||||
]) {
|
||||
assertContains(manifest, `"${toolName}"`, "manifest");
|
||||
assertContains(toolRoute, `"${toolName}"`, "tool dispatch");
|
||||
}
|
||||
assertContains(manifest, '"scope"', "WeKnora tool scope field");
|
||||
assertContains(manifest, '"allowlist"', "WeKnora tool allowlist field");
|
||||
assertContains(manifest, '"allowedRoots"', "WeKnora tool allowedRoots field");
|
||||
assertContains(manifest, '"aiAccessScope"', "WeKnora tool aiAccessScope field");
|
||||
assertContains(manifest, '"sourcePaths"', "WeKnora tool sourcePaths field");
|
||||
assertContains(hermesKnowledge, "mnote_weknora_scope_required", "WeKnora tool scope guard");
|
||||
|
||||
assertNotContains(ui, "打开 LightRAG", "UI default provider copy");
|
||||
assertNotContains(ui, "正在读取 LightRAG 状态", "UI default provider copy");
|
||||
assertNotContains(ui, "无法连接 LightRAG 服务", "UI default provider copy");
|
||||
assertContains(ui, "WeKnora", "UI provider label");
|
||||
assertContains(ui, "知识库 provider", "UI neutral copy");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [
|
||||
"rust/crates/mnote-web/src/routes/knowledge_rag.rs",
|
||||
"rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs",
|
||||
"rust/crates/mnote-web/src/hermes_tools/manifest.rs",
|
||||
"rust/crates/mnote-web/src/routes/hermes_tools.rs",
|
||||
"rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js",
|
||||
],
|
||||
provider: "weknora",
|
||||
assertions: {
|
||||
providerBoundary: true,
|
||||
providerIds: true,
|
||||
locatorDegraded: true,
|
||||
manifestFacade: true,
|
||||
scopeFields: true,
|
||||
uiNoDefaultLightRagCopy: true,
|
||||
ingestNoLightRagScan: true,
|
||||
deleteNoLightRagDelete: true,
|
||||
sectionContextNoSidecar: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,248 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
|
||||
const {
|
||||
setupWorkspaceAccess,
|
||||
getAiRuntimeRun,
|
||||
countAiRuntimeEvents,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-"));
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
|
||||
const whoami = await context.request.fetch(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS });
|
||||
const whoamiText = await whoami.text();
|
||||
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
|
||||
return JSON.parse(whoamiText);
|
||||
}
|
||||
|
||||
async function selectReasonix(page) {
|
||||
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function newestAssistantText(page, beforeCount) {
|
||||
return await page.evaluate((countBefore) => {
|
||||
const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'));
|
||||
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
|
||||
return node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
|
||||
}, beforeCount);
|
||||
}
|
||||
|
||||
function normalize(text) {
|
||||
return String(text || "").replace(/\s+/g, "").trim();
|
||||
}
|
||||
|
||||
async function sendPrompt(page, prompt, expectedCompact) {
|
||||
const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
|
||||
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
|
||||
null,
|
||||
{ timeout: Math.max(UI_TIMEOUT_MS, 120_000) },
|
||||
);
|
||||
const status = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "");
|
||||
assert.equal(status, "completed", `Page AI run 应完成,实际 status=${status}`);
|
||||
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: UI_TIMEOUT_MS });
|
||||
const text = await newestAssistantText(page, before);
|
||||
assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`);
|
||||
const runId = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-id") || "");
|
||||
assert(runId, "缺少 data-mnote-page-ai-run-id");
|
||||
return { runId, text };
|
||||
}
|
||||
|
||||
async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) {
|
||||
const start = Date.now();
|
||||
let last = null;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
last = await predicate().catch((error) => error);
|
||||
if (last === true) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-"));
|
||||
const rootUri = `file://${root}`;
|
||||
const relativePath = `Task558-${suffix}.md`;
|
||||
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const capturedRuns = [];
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return;
|
||||
try {
|
||||
capturedRuns.push(JSON.parse(request.postData() || "{}"));
|
||||
} catch {
|
||||
capturedRuns.push({ raw: request.postData() || "" });
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const viewer = await signIn(context);
|
||||
const actorId = viewer.userId || ACTOR;
|
||||
const workspaceId = `local-ws:${actorId}:task558-${suffix}`;
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(root, relativePath), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8");
|
||||
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
|
||||
await setupWorkspaceAccess(context.request, BASE_URL, {
|
||||
actorId,
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
displayName: actorId,
|
||||
role: "user",
|
||||
workspaceId,
|
||||
workspaceName: "Task558 Reasonix Live",
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
sourceKind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grantSource: "smoke",
|
||||
grantCreatedBy: actorId,
|
||||
timeoutMs: UI_TIMEOUT_MS,
|
||||
});
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await selectReasonix(page);
|
||||
|
||||
const firstMarker = `TASK558_FIRST_${suffix}`.toUpperCase();
|
||||
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
|
||||
const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
|
||||
await page.locator("[data-page-ai-input]").fill(
|
||||
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说"可以",你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => ["queued", "running", "tool_calling"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator("[data-page-ai-input]").fill("可以", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(document.querySelector('[data-page-ai-queue-item]')),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const queuedPreview = await page.locator('[data-page-ai-queue-item]').first().textContent({ timeout: UI_TIMEOUT_MS });
|
||||
assert(String(queuedPreview || '').includes("可以"), `queued preview 应包含第二轮短回复: ${queuedPreview}`);
|
||||
await waitUntil("reasonix_two_runs_started", async () => capturedRuns.length >= 2, Math.max(UI_TIMEOUT_MS, 120_000));
|
||||
await page.waitForFunction(
|
||||
([firstNeedle, secondNeedle]) => {
|
||||
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return text.includes(firstNeedle)
|
||||
&& text.includes(secondNeedle)
|
||||
&& ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "")
|
||||
&& !document.querySelector('[data-page-ai-streaming="true"]');
|
||||
},
|
||||
[firstMarker, secondMarker],
|
||||
{ timeout: Math.max(UI_TIMEOUT_MS, 120_000) },
|
||||
);
|
||||
const assistantTexts = await page.evaluate((countBefore) => {
|
||||
return Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'))
|
||||
.slice(countBefore)
|
||||
.map((node) => node.querySelector(".wolai-page-ai-message-text")?.textContent || "");
|
||||
}, beforeAssistantCount);
|
||||
assert(assistantTexts.some((text) => normalize(text) === firstMarker), `第一轮回复缺失: ${JSON.stringify(assistantTexts)}`);
|
||||
assert(assistantTexts.some((text) => normalize(text) === secondMarker), `第二轮回复缺失: ${JSON.stringify(assistantTexts)}`);
|
||||
assert.equal(capturedRuns.length, 2, `应捕获两次 Page AI run,实际 ${capturedRuns.length}`);
|
||||
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
|
||||
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
|
||||
|
||||
// 通过 API helper + capturedRuns 验证 DB 持久化和 acpSessionId 连续性,不再直读 SQLite
|
||||
const firstRunId = capturedRuns[0].runId;
|
||||
const secondRunId = capturedRuns[1].runId;
|
||||
const first = { runId: firstRunId, text: firstMarker };
|
||||
const second = { runId: secondRunId, text: secondMarker };
|
||||
|
||||
const firstRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: firstRunId });
|
||||
const secondRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: secondRunId });
|
||||
assert(firstRun, `first run ${firstRunId} 应 persist`);
|
||||
assert(secondRun, `second run ${secondRunId} 应 persist`);
|
||||
|
||||
const infoCount1 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: firstRunId, eventType: "session.info.updated" });
|
||||
const infoCount2 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: secondRunId, eventType: "session.info.updated" });
|
||||
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
|
||||
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
|
||||
|
||||
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
|
||||
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(acpSessionIds)}`);
|
||||
assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id");
|
||||
|
||||
const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png");
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
outDir: OUT_DIR,
|
||||
root,
|
||||
rootUri,
|
||||
workspaceId,
|
||||
documentId,
|
||||
first,
|
||||
second,
|
||||
queuedPreview,
|
||||
acpSessionId: acpSessionIds[0],
|
||||
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
|
||||
screenshotPath,
|
||||
};
|
||||
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(OUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
|
||||
fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), capturedRuns }, null, 2)}\n`, "utf8");
|
||||
throw error;
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
if (process.env.MNOTE_KEEP_TASK558_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const {
|
||||
setupWorkspaceAccess,
|
||||
getAiRuntimeRun,
|
||||
countAiRuntimeEvents,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_TERMINAL_STATUS_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task559-terminal-status-"));
|
||||
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => { request.destroy(); retry(); });
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function writeFakeReasonixAcp(scriptPath) {
|
||||
fs.writeFileSync(scriptPath, `
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
let nextId = 1;
|
||||
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
|
||||
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
|
||||
function markerFrom(text) { return (String(text || '').match(/TASK559_[A-Z0-9_]+/) || ['TASK559_FALLBACK'])[0]; }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (!msg.id) return;
|
||||
if (msg.method === 'initialize') {
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task559-fake-reasonix', version: '1.0' }, authMethods: [] } });
|
||||
} else if (msg.method === 'session/new') {
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task559_reasonix_session_' + nextId++ } });
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || '';
|
||||
const marker = markerFrom(promptText(msg.params?.prompt));
|
||||
for (let i = 0; i < 600; i += 1) {
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'thought_chunk', content: { type: 'text', text: 'thought-' + i + '\\n' } } } });
|
||||
}
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } });
|
||||
}
|
||||
});
|
||||
`, "utf8");
|
||||
}
|
||||
|
||||
async function signIn(context, baseUrl) {
|
||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } },
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${await response.text()}`);
|
||||
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
|
||||
return JSON.parse(await whoami.text());
|
||||
}
|
||||
|
||||
async function selectReasonix(page) {
|
||||
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs");
|
||||
writeFakeReasonixAcp(fakeScript);
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], title: "Reasonix Fake" }]);
|
||||
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(ROOT, "rust"),
|
||||
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task559-terminal-status-"));
|
||||
let browser;
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
|
||||
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const viewer = await signIn(context, baseUrl);
|
||||
const actorId = viewer.userId || ACTOR;
|
||||
const suffix = Date.now().toString(36).toUpperCase();
|
||||
const workspaceId = `local-ws:${actorId}:task559-${suffix.toLowerCase()}`;
|
||||
const rootUri = `file://${root}`;
|
||||
const relativePath = `Task559-${suffix}.md`;
|
||||
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(root, relativePath), `# Task559 Terminal Status\n\n${suffix}\n`, "utf8");
|
||||
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
|
||||
await setupWorkspaceAccess(context.request, baseUrl, {
|
||||
actorId,
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
displayName: actorId,
|
||||
role: "user",
|
||||
workspaceId,
|
||||
workspaceName: "Task559 Terminal Status",
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
sourceKind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grantSource: "smoke",
|
||||
grantCreatedBy: actorId,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
|
||||
await selectReasonix(page);
|
||||
const marker = `TASK559_DONE_${suffix}`;
|
||||
await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
|
||||
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
|
||||
assert(runId, "缺少 runId");
|
||||
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
|
||||
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker);
|
||||
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(runtimeStrip || '').includes('reasonix'), `runtime strip 应显示 reasonix: ${runtimeStrip}`);
|
||||
assert(!String(runtimeStrip || '').includes('输出中'), `完成后 runtime strip 不应残留输出中: ${runtimeStrip}`);
|
||||
// 通过 API helper 验证 run 持久化状态,不再直读 SQLite
|
||||
const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
|
||||
assert(persistedRun, `run ${runId} 应 persist`);
|
||||
assert.equal(persistedRun.status, "completed", "API run status 应为 completed");
|
||||
const terminalEventCount = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId, eventType: "run.completed" });
|
||||
assert(Number(terminalEventCount) >= 1, "应持久化 run.completed");
|
||||
const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png");
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
await page.locator('[data-page-ai-runtime-strip]').click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'status', null, { timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const detail = document.querySelector('[data-page-ai-runtime-detail]')?.textContent || '';
|
||||
const jobs = document.querySelector('[data-page-ai-jobs-list]')?.textContent || '';
|
||||
const logs = document.querySelector('[data-page-ai-logs-list]')?.textContent || '';
|
||||
const usage = document.querySelector('[data-page-ai-usage-detail]')?.textContent || '';
|
||||
return detail.includes('runtime') && detail.includes('MCP/tools') && jobs.length > 0 && logs.length > 0 && usage.length > 0;
|
||||
}, null, { timeout: TIMEOUT_MS });
|
||||
const statusText = await page.locator('[data-page-ai-panel="status"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(statusText || '').includes('Current runtime'), '状态页应显示 Current runtime');
|
||||
assert(String(statusText || '').includes('Queue'), '状态页应显示 Queue');
|
||||
assert(String(statusText || '').includes('Jobs'), '状态页应显示 Jobs');
|
||||
assert(String(statusText || '').includes('Logs tail'), '状态页应显示 Logs tail');
|
||||
assert(String(statusText || '').includes('Usage'), '状态页应显示 Usage');
|
||||
const runtimePanelScreenshotPath = path.join(OUT_DIR, "task559-runtime-panel.png");
|
||||
await page.screenshot({ path: runtimePanelScreenshotPath, fullPage: true });
|
||||
await page.setViewportSize({ width: 452, height: 900 });
|
||||
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
||||
const mobileTabs = await page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]').evaluateAll((nodes) => nodes.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { text: node.textContent.trim(), box: { width: rect.width, height: rect.height } };
|
||||
}));
|
||||
assert.deepEqual(mobileTabs.map((tab) => tab.text), ['MNote', 'Reasonix', 'Hermes'], `移动宽度设置 tabs 应保持三层: ${JSON.stringify(mobileTabs)}`);
|
||||
assert(mobileTabs.every((tab) => tab.box.width > 40 && tab.box.height > 24), `移动宽度 tabs 不应挤压不可点: ${JSON.stringify(mobileTabs)}`);
|
||||
const mobileSettingsScreenshotPath = path.join(OUT_DIR, "task559-settings-mobile.png");
|
||||
await page.screenshot({ path: mobileSettingsScreenshotPath, fullPage: true });
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath, runtimePanelScreenshotPath, mobileSettingsScreenshotPath };
|
||||
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
await context.close().catch(() => undefined);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8");
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
if (process.env.MNOTE_KEEP_TASK559_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const {
|
||||
setupWorkspaceAccess,
|
||||
getAiRuntimeRun,
|
||||
countAiRuntimeEvents,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_HERMES_LOAD_REPLAY_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task560-hermes-load-replay-"));
|
||||
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => { request.destroy(); retry(); });
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function writeFakeHermesAcp(scriptPath) {
|
||||
fs.writeFileSync(scriptPath, `
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
import fs from 'node:fs';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
const replay = process.env.MNOTE_TASK560_HERMES_REPLAY === '1';
|
||||
const logPath = process.env.MNOTE_TASK560_FAKE_LOG || '';
|
||||
let nextId = 1;
|
||||
function log(entry) { if (logPath) fs.appendFileSync(logPath, JSON.stringify({ ...entry, at: Date.now() }) + '\\n'); }
|
||||
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
|
||||
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
|
||||
function markerFrom(text) { return (String(text || '').match(/TASK560_[A-Z0-9_]+/) || ['TASK560_FALLBACK'])[0]; }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (!msg.id) return;
|
||||
if (msg.method === 'initialize') {
|
||||
log({ method: msg.method });
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: true }, agentInfo: { name: 'task560-fake-hermes', version: '1.0' }, authMethods: [] } });
|
||||
} else if (msg.method === 'session/new') {
|
||||
const sessionId = 'task560_hermes_session_' + nextId++;
|
||||
log({ method: msg.method, sessionId });
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } });
|
||||
} else if (msg.method === 'session/load') {
|
||||
const sessionId = msg.params?.sessionId || 'missing_session';
|
||||
log({ method: msg.method, sessionId, replay });
|
||||
if (replay) {
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'TASK560_REPLAY_SHOULD_NOT_APPEAR' } } } });
|
||||
}
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } });
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || '';
|
||||
const marker = markerFrom(promptText(msg.params?.prompt));
|
||||
log({ method: msg.method, sessionId, marker });
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } });
|
||||
}
|
||||
});
|
||||
`, "utf8");
|
||||
}
|
||||
|
||||
async function signIn(context, baseUrl) {
|
||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } },
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
|
||||
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
|
||||
const whoamiText = await whoami.text();
|
||||
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
|
||||
return JSON.parse(whoamiText);
|
||||
}
|
||||
|
||||
async function selectHermes(page) {
|
||||
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-agent-id="hermes"]').first().click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-mnote-acp-runtime') === 'hermes',
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
function normalize(text) {
|
||||
return String(text || "").replace(/\s+/g, "").trim();
|
||||
}
|
||||
|
||||
async function newestAssistantText(page, beforeCount) {
|
||||
return await page.evaluate((countBefore) => {
|
||||
const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'));
|
||||
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
|
||||
return node?.querySelector('.wolai-page-ai-message-text')?.textContent || '';
|
||||
}, beforeCount);
|
||||
}
|
||||
|
||||
async function sendPrompt(page, prompt, expectedCompact) {
|
||||
const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
|
||||
await page.locator('[data-page-ai-input]').fill(prompt, { timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => ['completed', 'failed', 'aborted'].includes(document.documentElement.getAttribute('data-mnote-page-ai-run-status') || ''),
|
||||
null,
|
||||
{ timeout: Math.max(TIMEOUT_MS, 120_000) },
|
||||
);
|
||||
const status = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') || '');
|
||||
assert.equal(status, 'completed', `Page AI run 应完成,实际 status=${status}`);
|
||||
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
|
||||
const text = await newestAssistantText(page, before);
|
||||
assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`);
|
||||
assert(!normalize(text).includes('TASK560_REPLAY_SHOULD_NOT_APPEAR'), 'Hermes replay 不应出现在当前 assistant 正文');
|
||||
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
|
||||
assert(runId, '缺少 data-mnote-page-ai-run-id');
|
||||
return { runId, text };
|
||||
}
|
||||
|
||||
function readFakeLog(logPath) {
|
||||
if (!fs.existsSync(logPath)) return [];
|
||||
return fs.readFileSync(logPath, 'utf8').trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
async function runVariant({ replay }) {
|
||||
const variant = replay ? 'with-replay' : 'no-replay';
|
||||
const variantDir = path.join(OUT_DIR, variant);
|
||||
fs.mkdirSync(variantDir, { recursive: true });
|
||||
const fakeScript = path.join(variantDir, 'fake-hermes-acp.mjs');
|
||||
const fakeLog = path.join(variantDir, 'fake-hermes-acp.jsonl');
|
||||
writeFakeHermesAcp(fakeScript);
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const runtimes = JSON.stringify([{ name: 'hermes', bin: 'node', args: [fakeScript], env: { MNOTE_TASK560_HERMES_REPLAY: replay ? '1' : '0', MNOTE_TASK560_FAKE_LOG: fakeLog }, title: 'Hermes Fake' }]);
|
||||
const server = spawn('cargo', ['run', '-q', '-p', 'mnote-web', '--bin', 'mnote-web'], {
|
||||
cwd: path.join(ROOT, 'rust'),
|
||||
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: 'hermes' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
server.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
server.stdout.on('data', () => {});
|
||||
let browser;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-task560-hermes-${variant}-`));
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
|
||||
browser = await chromium.launch({ headless: process.env.HEADFUL !== '1', executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const capturedRuns = [];
|
||||
page.on('request', (request) => {
|
||||
if (!request.url().includes('/api/hermes/client/runs') || request.method() !== 'POST') return;
|
||||
try { capturedRuns.push(JSON.parse(request.postData() || '{}')); } catch { capturedRuns.push({ raw: request.postData() || '' }); }
|
||||
});
|
||||
const viewer = await signIn(context, baseUrl);
|
||||
const actorId = viewer.userId || ACTOR;
|
||||
const suffix = `${Date.now().toString(36)}_${variant.replace('-', '_')}`.toUpperCase();
|
||||
const workspaceId = `local-ws:${actorId}:task560-${suffix.toLowerCase()}`;
|
||||
const rootUri = `file://${root}`;
|
||||
const relativePath = `Task560-${suffix}.md`;
|
||||
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
fs.mkdirSync(path.join(root, '.mnote'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, '.mnote', 'workspace.json'), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, 'utf8');
|
||||
fs.writeFileSync(path.join(root, relativePath), `# Task560 Hermes Load Replay\n\n${suffix}\n`, 'utf8');
|
||||
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
|
||||
await setupWorkspaceAccess(context.request, baseUrl, {
|
||||
actorId,
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
displayName: actorId,
|
||||
role: "user",
|
||||
workspaceId,
|
||||
workspaceName: "Task560 Hermes Load Replay",
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
sourceKind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grantSource: "smoke",
|
||||
grantCreatedBy: actorId,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
|
||||
await page.getByTestId('wolai-floating-ai').click({ timeout: TIMEOUT_MS });
|
||||
await selectHermes(page);
|
||||
|
||||
const firstMarker = `TASK560_FIRST_${suffix}`;
|
||||
const secondMarker = `TASK560_SECOND_${suffix}`;
|
||||
const first = await sendPrompt(page, `只回复 ${firstMarker},不要解释。`, firstMarker);
|
||||
const second = await sendPrompt(page, `只回复 ${secondMarker},不要解释。`, secondMarker);
|
||||
assert.equal(capturedRuns.length, 2, `应捕获两次 Hermes Page AI run,实际 ${capturedRuns.length}`);
|
||||
assert(capturedRuns.every((body) => body.acpRuntime === 'hermes'), '两次 run 都应走 Hermes ACP');
|
||||
assert(capturedRuns[1].acpSessionId, '第二轮请求应携带上一轮 acpSessionId');
|
||||
|
||||
// 通过 API helper + capturedRuns + fake ACP log 验证持久化和 replay 行为,不再直读 SQLite events
|
||||
const firstRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: first.runId });
|
||||
const secondRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: second.runId });
|
||||
assert(firstRun, `first run ${first.runId} 应 persist`);
|
||||
assert(secondRun, `second run ${second.runId} 应 persist`);
|
||||
|
||||
const infoCount1 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: first.runId, eventType: 'session.info.updated' });
|
||||
const infoCount2 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: second.runId, eventType: 'session.info.updated' });
|
||||
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
|
||||
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
|
||||
|
||||
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
|
||||
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(acpSessionIds)}`);
|
||||
|
||||
// adapter_replay 事件的 payload filter(source === 'adapter_replay')需要新的 dev seed kind
|
||||
// (listAiRuntimeEvents 或 countByPayloadField),当前通过 fake ACP log 验证 session/load 行为
|
||||
const fakeLogRows = readFakeLog(fakeLog);
|
||||
assert(fakeLogRows.some((row) => row.method === 'session/new'), '第一轮应调用 session/new');
|
||||
assert(fakeLogRows.some((row) => row.method === 'session/load' && row.sessionId === acpSessionIds[0]), '第二轮应先 session/load stored acpSessionId');
|
||||
const screenshotPath = path.join(variantDir, 'task560-hermes-load-replay.png');
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], fakeLogRows, capturedRuns, screenshotPath };
|
||||
fs.writeFileSync(path.join(variantDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
await context.close().catch(() => undefined);
|
||||
return result;
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(variantDir, 'failure.json'), `${JSON.stringify({ ok: false, variant, error: String(error && error.stack || error), stderr, fakeLog: readFakeLog(fakeLog) }, null, 2)}\n`, 'utf8');
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
server.kill('SIGTERM');
|
||||
await new Promise((resolve) => server.once('exit', resolve));
|
||||
if (process.env.MNOTE_KEEP_TASK560_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const results = [];
|
||||
results.push(await runVariant({ replay: false }));
|
||||
results.push(await runVariant({ replay: true }));
|
||||
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, screenshotPath: result.screenshotPath })) };
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const {
|
||||
setupWorkspaceAccess,
|
||||
seedAiRuntime,
|
||||
getAiRuntimeRun,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_SESSION_DASHBOARD_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task561-session-dashboard-"));
|
||||
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => { request.destroy(); retry(); });
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(context, baseUrl) {
|
||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } },
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
|
||||
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
|
||||
return JSON.parse(await whoami.text());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(ROOT, "rust"),
|
||||
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}` },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task561-session-dashboard-"));
|
||||
let browser;
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
|
||||
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const viewer = await signIn(context, baseUrl);
|
||||
const actorId = viewer.userId || ACTOR;
|
||||
const suffix = Date.now().toString(36);
|
||||
const workspaceId = `local-ws:${actorId}:task561-${suffix}`;
|
||||
const sessionId = `mnote_task561_${suffix}`;
|
||||
const runId = `run_task561_${suffix}`;
|
||||
const rootUri = `file://${root}`;
|
||||
const relativePath = `Task561-${suffix}.md`;
|
||||
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(root, relativePath), `# Task561 Dashboard\n\n${suffix}\n`, "utf8");
|
||||
// 通过 API helper 写入 workspace/用户/授权 + run/events,不再直写 SQLite
|
||||
await setupWorkspaceAccess(context.request, baseUrl, {
|
||||
actorId,
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
displayName: actorId,
|
||||
role: "user",
|
||||
workspaceId,
|
||||
workspaceName: "Task561 Session Dashboard",
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
sourceKind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grantSource: "smoke",
|
||||
grantCreatedBy: actorId,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
await seedAiRuntime(context.request, baseUrl, {
|
||||
id: `airun_${runId}`,
|
||||
userId: actorId,
|
||||
workspaceId,
|
||||
documentId,
|
||||
sessionId,
|
||||
runId,
|
||||
title: `Task561 Seed ${suffix}`,
|
||||
profile: "reasonix",
|
||||
acpRuntime: "reasonix",
|
||||
traceId: `trace_${suffix}`,
|
||||
status: "completed",
|
||||
runtimeJson: { status: "completed", acpSessionId: `acp_${suffix}` },
|
||||
payloadJson: { message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: "reasonix", acpRuntime: "reasonix" },
|
||||
events: [
|
||||
{ id: `aievt_${runId}_1`, eventType: "message.delta", payloadJson: { delta: `Task561 assistant answer ${suffix}` } },
|
||||
{ id: `aievt_${runId}_2`, eventType: "run.completed", payloadJson: { stopReason: "EndTurn" } },
|
||||
],
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
||||
const settingTabs = page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]');
|
||||
const settingTabTexts = await settingTabs.evaluateAll((nodes) => nodes.map((node) => node.textContent.trim()).filter(Boolean));
|
||||
assert.deepEqual(settingTabTexts, ['MNote', 'Reasonix', 'Hermes'], `设置页 tabs 应只包含 MNote / Reasonix / Hermes: ${JSON.stringify(settingTabTexts)}`);
|
||||
assert(!settingTabTexts.includes('Common') && !settingTabTexts.includes('Chat-only') && !settingTabTexts.includes('高级') && !settingTabTexts.includes('Runtime'), `设置页不应显示旧一级 tab: ${JSON.stringify(settingTabTexts)}`);
|
||||
const mnotePanelText = await page.locator('[data-page-ai-panel="agent"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(mnotePanelText || '').includes('授权区域'), 'MNote tab 应显示授权区域');
|
||||
assert(String(mnotePanelText || '').includes('默认上下文'), 'MNote tab 应显示默认上下文');
|
||||
assert(String(mnotePanelText || '').includes('MNote 工具'), 'MNote tab 应显示 MNote 工具');
|
||||
assert(String(mnotePanelText || '').includes('会话与审计'), 'MNote tab 应显示会话与审计');
|
||||
await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: TIMEOUT_MS });
|
||||
const reasonixPanelText = await page.locator('[data-page-ai-panel="reasonix-settings"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(reasonixPanelText || '').includes('ACP runtime'), 'Reasonix tab 应显示 ACP runtime');
|
||||
assert(!/MNOTE_WEB_HERMES_UPSTREAM_URL|API key|Hermes 设置入口/.test(String(reasonixPanelText || '')), `Reasonix tab 不应显示 Hermes gateway/API key 错误: ${reasonixPanelText}`);
|
||||
await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="hermes-settings"]').click({ timeout: TIMEOUT_MS });
|
||||
const hermesPanelText = await page.locator('[data-page-ai-panel="hermes-settings"]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(hermesPanelText || '').includes('Hermes profile'), 'Hermes tab 应显示 profile');
|
||||
assert(String(hermesPanelText || '').includes('Hermes gateway'), 'Hermes tab 应显示 gateway');
|
||||
assert(!String(hermesPanelText || '').includes('native-live queue'), 'Hermes tab 不应显示 Reasonix native-live queue 当前状态');
|
||||
const settingsScreenshotPath = path.join(OUT_DIR, "task561-settings-ia.png");
|
||||
await page.screenshot({ path: settingsScreenshotPath, fullPage: true });
|
||||
await page.locator('[data-page-ai-action="history"]').first().click({ timeout: TIMEOUT_MS });
|
||||
const row = page.locator(`[data-page-ai-session-row="${sessionId}"]`).first();
|
||||
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
const statusFilter = page.locator('[data-page-ai-session-status-filter]');
|
||||
await statusFilter.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
const statusValues = await statusFilter.locator('option').evaluateAll((options) => options.map((option) => option.value));
|
||||
assert(statusValues.includes('completed'), `status filter 应包含 completed: ${JSON.stringify(statusValues)}`);
|
||||
assert.equal(await row.getAttribute('data-page-ai-session-status'), 'completed');
|
||||
await statusFilter.selectOption('completed', { timeout: TIMEOUT_MS });
|
||||
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-session-search]').fill(`dashboard seed ${suffix}`, { timeout: TIMEOUT_MS });
|
||||
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
const beforeDeleteScreenshotPath = path.join(OUT_DIR, "task561-session-dashboard-before-delete.png");
|
||||
await page.screenshot({ path: beforeDeleteScreenshotPath, fullPage: true });
|
||||
const exportApiResponse = await context.request.fetch(`${baseUrl}/api/page-ai/sessions/${encodeURIComponent(sessionId)}/export?workspaceId=${encodeURIComponent(workspaceId)}&documentId=${encodeURIComponent(documentId)}&limit=200`, { timeout: TIMEOUT_MS });
|
||||
const exportApiText = await exportApiResponse.text();
|
||||
assert(exportApiResponse.ok(), `/api/page-ai/sessions export 失败: ${exportApiResponse.status()} ${exportApiText.slice(0, 500)}`);
|
||||
const exportApiPayload = JSON.parse(exportApiText);
|
||||
assert.deepEqual(exportApiPayload.export.formats, ['json', 'markdown', 'jsonl']);
|
||||
assert(String(exportApiPayload.export.markdown || '').includes(sessionId), 'markdown export 应包含 sessionId');
|
||||
assert(String(exportApiPayload.export.jsonl || '').includes('"eventType"'), 'jsonl export 应包含事件行');
|
||||
await page.locator(`[data-page-ai-session-export="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction((id) => document.documentElement.getAttribute('data-mnote-page-ai-session-exported') === id, sessionId, { timeout: TIMEOUT_MS });
|
||||
await page.evaluate(() => { window.prompt = () => 'Task561 Renamed'; });
|
||||
await page.locator(`[data-page-ai-session-rename="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction((id) => {
|
||||
const row = document.querySelector(`[data-page-ai-session-row="${id}"]`);
|
||||
return row && row.textContent.includes('Task561 Renamed');
|
||||
}, sessionId, { timeout: TIMEOUT_MS });
|
||||
await page.evaluate(() => { window.confirm = () => true; });
|
||||
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction((id) => !document.querySelector(`[data-page-ai-session-row="${id}"]`), sessionId, { timeout: TIMEOUT_MS });
|
||||
// 通过 API helper 验证软删除,不再直读 SQLite
|
||||
const deletedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
|
||||
assert(deletedRun, `run ${runId} 应仍 persist(软删除)`);
|
||||
assert(deletedRun.deletedAt, "delete 应软删除 SQLite session runs");
|
||||
const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png");
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, settingsScreenshotPath, beforeDeleteScreenshotPath, screenshotPath };
|
||||
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
await context.close().catch(() => undefined);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8");
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
if (process.env.MNOTE_KEEP_TASK561_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });
|
||||
@@ -1,311 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const {
|
||||
setupWorkspaceAccess,
|
||||
getAiRuntimeRun,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-"));
|
||||
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => { request.destroy(); retry(); });
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function writeFakeReasonixAcp(scriptPath) {
|
||||
fs.writeFileSync(scriptPath, `
|
||||
import * as readline from 'node:readline';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
const LOG = process.env.TASK562_FAKE_LOG || '';
|
||||
let nextSessionId = 1;
|
||||
let pendingPrompt = null;
|
||||
let permissionRequestId = 7;
|
||||
let permissionId = 'task562_perm_1';
|
||||
let marker = 'TASK562_DONE';
|
||||
function log(value) { if (LOG) appendFileSync(LOG, JSON.stringify(value) + '\\n', 'utf8'); }
|
||||
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
|
||||
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
log({ dir: 'in', method: msg.method || '', id: msg.id || null, hasResult: Boolean(msg.result), hasError: Boolean(msg.error) });
|
||||
if (msg.method === 'initialize') {
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task562-fake-reasonix', version: '1.0' }, authMethods: [] } });
|
||||
return;
|
||||
}
|
||||
if (msg.method === 'session/new') {
|
||||
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task562_reasonix_session_' + nextSessionId++ } });
|
||||
return;
|
||||
}
|
||||
if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || '';
|
||||
const prompt = promptText(msg.params?.prompt);
|
||||
marker = (String(prompt).match(/TASK562_[A-Z0-9_]+/) || ['TASK562_DONE'])[0];
|
||||
pendingPrompt = { id: msg.id, sessionId, prompt };
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: permissionRequestId,
|
||||
method: 'session/request_permission',
|
||||
params: {
|
||||
permissionId,
|
||||
toolName: 'mcp__demo__write_file',
|
||||
toolCall: {
|
||||
toolCallId: 'gate-call_1',
|
||||
title: 'bash',
|
||||
kind: 'execute',
|
||||
rawInput: { command: 'echo reasonix permission' }
|
||||
},
|
||||
options: [
|
||||
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' }
|
||||
]
|
||||
}
|
||||
});
|
||||
log({ dir: 'out', method: 'session/request_permission', id: permissionRequestId, permissionId });
|
||||
return;
|
||||
}
|
||||
if (msg.id === permissionRequestId && msg.result) {
|
||||
log({ dir: 'permission-resolved', result: msg.result });
|
||||
if (!pendingPrompt) return;
|
||||
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: pendingPrompt.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
|
||||
send({ jsonrpc: '2.0', id: pendingPrompt.id, result: { stopReason: 'end_turn' } });
|
||||
pendingPrompt = null;
|
||||
}
|
||||
});
|
||||
`, "utf8");
|
||||
}
|
||||
|
||||
async function signIn(context, baseUrl) {
|
||||
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
|
||||
},
|
||||
},
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
|
||||
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
|
||||
const whoamiText = await whoami.text();
|
||||
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
|
||||
return JSON.parse(whoamiText);
|
||||
}
|
||||
|
||||
async function selectReasonix(page) {
|
||||
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs");
|
||||
const fakeLog = path.join(OUT_DIR, "fake-reasonix-acp.jsonl");
|
||||
writeFakeReasonixAcp(fakeScript);
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], env: { TASK562_FAKE_LOG: fakeLog }, title: "Reasonix Fake" }]);
|
||||
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(ROOT, "rust"),
|
||||
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task562-reasonix-approval-"));
|
||||
let browser;
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
|
||||
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const viewer = await signIn(context, baseUrl);
|
||||
const actorId = viewer.userId || ACTOR;
|
||||
const suffix = Date.now().toString(36).toUpperCase();
|
||||
const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`;
|
||||
const rootUri = `file://${root}`;
|
||||
const relativePath = `Task562-${suffix}.md`;
|
||||
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8");
|
||||
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
|
||||
await setupWorkspaceAccess(context.request, baseUrl, {
|
||||
actorId,
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
displayName: actorId,
|
||||
role: "user",
|
||||
workspaceId,
|
||||
workspaceName: "Task562 Reasonix Approval",
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
sourceKind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grantSource: "smoke",
|
||||
grantCreatedBy: actorId,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
|
||||
await selectReasonix(page);
|
||||
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-tab="reasonix-settings"]').first().click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'reasonix-settings',
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const modelSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.model_id"]');
|
||||
const approvalSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.approval_mode"]');
|
||||
const planSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.plan_mode"]');
|
||||
await modelSelect.selectOption("mimo-pro", { timeout: TIMEOUT_MS });
|
||||
await approvalSelect.selectOption("ask", { timeout: TIMEOUT_MS });
|
||||
await planSelect.selectOption("auto", { timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.querySelector('[data-page-ai-model-status]')?.textContent || '';
|
||||
return text.includes('Reasonix') && text.includes('mimo-pro') && text.includes('审批:询问') && text.includes('计划:自动');
|
||||
},
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const buttons = Array.from(drawer ? drawer.querySelectorAll('[data-page-ai-tab="chat"]') : []);
|
||||
const button = buttons.find((node) => node instanceof HTMLElement && node.offsetParent !== null);
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'chat',
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForSelector('[data-page-ai-reasonix-quick-controls]:not([hidden])', { timeout: TIMEOUT_MS });
|
||||
const quickControlsText = await page.locator('[data-page-ai-reasonix-quick-controls]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(quickControlsText || '').includes('模型'), `聊天输入区应显示模型选择: ${quickControlsText}`);
|
||||
assert(String(quickControlsText || '').includes('审批'), `聊天输入区应显示审批选择: ${quickControlsText}`);
|
||||
assert(String(quickControlsText || '').includes('Plan'), `聊天输入区应显示 Plan 选择: ${quickControlsText}`);
|
||||
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.model_id"]').inputValue({ timeout: TIMEOUT_MS }), 'mimo-pro', '模型 quick control 应同步设置值');
|
||||
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.approval_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'ask', '审批 quick control 应同步设置值');
|
||||
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.plan_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'auto', 'Plan quick control 应同步设置值');
|
||||
|
||||
const marker = `TASK562_DONE_${suffix}`;
|
||||
await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => !!document.querySelector('[data-page-ai-permission-dialog]') && !document.querySelector('[data-page-ai-permission-dialog]')?.hidden,
|
||||
null,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
const permissionText = await page.locator('[data-page-ai-permission-dialog]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(permissionText || '').includes('bash') && String(permissionText || '').includes('echo reasonix permission'), `权限弹窗应展示 tool 和命令: ${permissionText}`);
|
||||
const resolveRequestPromise = page.waitForRequest(
|
||||
(request) => request.url().includes('/resolve-permission') && request.method() === 'POST',
|
||||
{ timeout: 5000 },
|
||||
).catch(() => null);
|
||||
const resolveResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/resolve-permission'),
|
||||
{ timeout: 5000 },
|
||||
).catch(() => null);
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-page-ai-permission-dialog] [data-page-ai-permission-action="allow"]');
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
});
|
||||
const resolveRequest = await resolveRequestPromise;
|
||||
assert(resolveRequest, '点击允许后应发出 resolve-permission 请求');
|
||||
const resolveResponse = await resolveResponsePromise;
|
||||
assert(resolveResponse && resolveResponse.ok(), `resolve-permission 应成功,实际 status=${resolveResponse && resolveResponse.status()} url=${resolveRequest.url()} body=${resolveRequest.postData() || ''}`);
|
||||
await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
|
||||
|
||||
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
|
||||
assert(runId, '缺少 runId');
|
||||
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
|
||||
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker, `AI 回复不符合预期: ${JSON.stringify(text && text.slice(0, 800))}`);
|
||||
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
|
||||
assert(String(runtimeStrip || '').includes('Reasonix'), `runtime strip 应显示 Reasonix: ${runtimeStrip}`);
|
||||
assert(String(runtimeStrip || '').includes('mimo-pro'), `runtime strip 应显示已选 model: ${runtimeStrip}`);
|
||||
assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`);
|
||||
assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`);
|
||||
|
||||
// 通过 API helper 读取 run 持久化状态,不再直读 SQLite
|
||||
const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
|
||||
assert(persistedRun, `run ${runId} 应 persist`);
|
||||
assert.equal(persistedRun.status, 'completed', 'API run status 应为 completed');
|
||||
const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png');
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
await context.close().catch(() => undefined);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'failure.json'), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, 'utf8');
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
server.kill('SIGTERM');
|
||||
await new Promise((resolve) => server.once('exit', resolve));
|
||||
if (process.env.MNOTE_KEEP_TASK562_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });
|
||||
@@ -1,433 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
ensureAuthenticated,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task762-page-ai-board-first-smoke";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".mnote", "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function documentUrl(root, workspaceId, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, expected) {
|
||||
await page.waitForFunction(
|
||||
(text) => {
|
||||
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
|
||||
return (editor?.textContent || "").includes(text);
|
||||
},
|
||||
expected,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function saveScreenshot(page, name) {
|
||||
const target = path.join(OUTPUT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: target, fullPage: true });
|
||||
return target;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const actorId = "mnote-e2e";
|
||||
const workspaceId = `local-ws:${actorId}:task762-${suffix}`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task762-board-ai-"));
|
||||
const rootUri = fileUrl(root);
|
||||
const relativePath = "BoardFirstPage.md";
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const filePath = path.join(root, relativePath);
|
||||
const beforeToken = `BOARD_UI_EDIT_BEFORE_${suffix}`;
|
||||
const afterToken = `BOARD_UI_EDIT_AFTER_${suffix}`;
|
||||
const capturedBoardRuns = [];
|
||||
const capturedBoardRunResponses = [];
|
||||
const consoleErrors = [];
|
||||
let caughtError = null;
|
||||
|
||||
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
["# Board First Page AI", "", `页面段落:${beforeToken}`, ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": actorId,
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
consoleErrors.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/page-ai/board/runs") || request.method() !== "POST") return;
|
||||
let body = null;
|
||||
try {
|
||||
body = JSON.parse(request.postData() || "{}");
|
||||
} catch {
|
||||
body = request.postData() || "";
|
||||
}
|
||||
capturedBoardRuns.push({ url: request.url(), method: request.method(), body });
|
||||
});
|
||||
|
||||
await page.route("**/api/page-ai/board/workers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "agent_board.page_ai_route.v2",
|
||||
surface: "mnote-page-ai",
|
||||
workerPresetId: "mnote-page-ai-zcode",
|
||||
workerName: "MNote 页面 AI · ZCode",
|
||||
allowedWorkerPresetIds: ["mnote-page-ai-zcode"],
|
||||
modelOverride: "zcode-default",
|
||||
modelOptions: [
|
||||
{ id: "zcode-default", label: "默认", default: true },
|
||||
{ id: "zcode-fast", label: "快速" },
|
||||
{ id: "zcode-strong", label: "强力" },
|
||||
],
|
||||
workers: [{
|
||||
id: "mnote-page-ai-zcode",
|
||||
name: "MNote 页面 AI · ZCode",
|
||||
surface: "mnote-page-ai",
|
||||
agentType: "zcode",
|
||||
modelOptions: [
|
||||
{ id: "zcode-default", label: "默认", default: true },
|
||||
{ id: "zcode-fast", label: "快速" },
|
||||
{ id: "zcode-strong", label: "强力" },
|
||||
],
|
||||
}, {
|
||||
id: "qa-browser-worker",
|
||||
name: "QA Browser Worker",
|
||||
surface: "agent-board-console",
|
||||
role: "qa",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/page-ai/board/workflows", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "agent_board.page_ai_route.v2",
|
||||
surface: "mnote-page-ai",
|
||||
workflowId: "builtin-mnote-page-ai-chat",
|
||||
workflowName: "MNote 页面 AI",
|
||||
allowedWorkflowIds: ["builtin-mnote-page-ai-chat"],
|
||||
workflows: [{
|
||||
id: "builtin-mnote-page-ai-chat",
|
||||
name: "MNote 页面 AI",
|
||||
surface: "mnote-page-ai",
|
||||
}, {
|
||||
id: "general-hotfix-workflow",
|
||||
name: "通用 hotfix workflow",
|
||||
surface: "agent-board-console",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/page-ai/board/runs", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
capturedBoardRunResponses.push({ kind: "create", body });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_ai_board_run.v1",
|
||||
surface: "mnote-page-ai",
|
||||
runId: `board-run-${suffix}`,
|
||||
workflowId: body.workflowId,
|
||||
workerPresetId: body.workerPresetId,
|
||||
modelOverride: body.modelOverride,
|
||||
board: { run: { id: `board-run-${suffix}`, status: "running" } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/page-ai/board/runs/board-run-${suffix}`, async (route) => {
|
||||
capturedBoardRunResponses.push({ kind: "get" });
|
||||
fs.writeFileSync(filePath, ["# Board First Page AI", "", `页面段落:${afterToken}`, ""].join("\n"), "utf8");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_ai_board_run_status.v1",
|
||||
runId: `board-run-${suffix}`,
|
||||
board: {
|
||||
run: { id: `board-run-${suffix}`, status: "complete" },
|
||||
events: [
|
||||
{ type: "workflow.started", message: "run started", createdAt: new Date().toISOString() },
|
||||
{ type: "worker.completed", message: "file edited", createdAt: new Date().toISOString() },
|
||||
],
|
||||
},
|
||||
receipt: {
|
||||
schema: "agent_board.workflow_run_receipt.v2",
|
||||
runId: `board-run-${suffix}`,
|
||||
surface: "mnote-page-ai",
|
||||
workflowId: "builtin-mnote-page-ai-chat",
|
||||
workerPresetId: "mnote-page-ai-zcode",
|
||||
modelOverride: "zcode-fast",
|
||||
finalAnswer: `已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`,
|
||||
changedFiles: [{ path: filePath, status: "modified" }],
|
||||
verification: [{ command: "read file", status: "passed", output: afterToken }],
|
||||
remaining: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await page.route("**/api/user/access-policy**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
controlPlane: "sqlite",
|
||||
grants: [{
|
||||
id: `grant_task762_${suffix}`,
|
||||
userId: actorId,
|
||||
workspaceId,
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
permission: "write",
|
||||
recursive: true,
|
||||
capabilities: ["ai", "markdown_edit"],
|
||||
source: "smoke",
|
||||
status: "active",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ui/preferences**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const response = await page.goto(documentUrl(root, workspaceId, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await waitForEditorText(page, beforeToken);
|
||||
await page.evaluate(() => localStorage.removeItem("mnote.page_ai.legacy_provider_mode"));
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return drawerText.includes("MNote 页面 AI · ZCode") && !drawerText.includes("QA Browser Worker") && !drawerText.includes("通用 hotfix workflow");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const modelSelect = page.locator("[data-page-ai-board-model]");
|
||||
await modelSelect.selectOption("zcode-fast", { timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.locator("[data-page-ai-input]").fill(
|
||||
`请编辑当前页面真实 Markdown 文件,把 ${beforeToken} 替换为 ${afterToken}。完成后说明 MNote capability 已加载。`,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return text.includes("正在处理") || text.includes(expected);
|
||||
},
|
||||
afterToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const subtitle = drawer?.querySelector('.wolai-page-ai-subtitle')?.textContent || "";
|
||||
const agentChip = drawer?.querySelector('[data-page-ai-agent-chip]')?.textContent || "";
|
||||
const hiddenLegacyTabs = Array.from(drawer?.querySelectorAll('[data-page-ai-tab="agent"], [data-page-ai-tab="reasonix-settings"], [data-page-ai-tab="hermes-settings"]') || [])
|
||||
.every((node) => node instanceof HTMLElement && node.hidden === true);
|
||||
return subtitle.includes("Agent Board") && agentChip.includes("MNote 页面 AI") && hiddenLegacyTabs;
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed",
|
||||
null,
|
||||
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected),
|
||||
afterToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const finalDiskContent = fs.readFileSync(filePath, "utf8");
|
||||
assert(finalDiskContent.includes(afterToken), "磁盘文件应包含 Board worker 写入后的标记");
|
||||
assert(!finalDiskContent.includes(beforeToken), "磁盘文件不应再包含旧标记");
|
||||
await waitForEditorText(page, afterToken);
|
||||
assert.strictEqual(capturedBoardRuns.length, 1, `应只创建一个 Board Page AI run,实际 ${capturedBoardRuns.length}`);
|
||||
const runBody = capturedBoardRuns[0].body;
|
||||
assert.strictEqual(runBody.workflowId, "builtin-mnote-page-ai-chat", "Page AI 应默认走 MNote 专用 Board workflow");
|
||||
assert.strictEqual(runBody.workerPresetId, "mnote-page-ai-zcode", "Page AI 应默认走 MNote 专用 ZCode worker");
|
||||
assert.strictEqual(runBody.modelOverride, "zcode-fast", "Page AI 应传递 worker 内模型档位");
|
||||
assert.strictEqual(runBody.envelope?.schema, "mnote.page_ai.board_task.v1", "run payload 应携带 Board envelope");
|
||||
assert.strictEqual(runBody.envelope?.modelOverride, "zcode-fast", "envelope 应记录模型档位");
|
||||
assert.strictEqual(runBody.envelope?.primaryTarget?.absolutePath, filePath, "primaryTarget 应指向当前真实 Markdown 文件");
|
||||
assert(Array.isArray(runBody.envelope?.capabilities), "envelope 应携带 MNote capabilities");
|
||||
assert(runBody.envelope.capabilities.includes("mnote.current_page.read"), "capabilities 应包含当前页读取能力");
|
||||
assert(runBody.envelope.capabilities.includes("mnote.local_file.receipt"), "capabilities 应包含本地文件收据能力");
|
||||
|
||||
const drawerText = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!drawerText.includes("Agent Board run 已创建"), "默认聊天面不应把 Board run 创建日志作为 assistant 气泡显示");
|
||||
assert(drawerText.includes(`已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`), "主聊天应展示 receipt.finalAnswer 自然回复");
|
||||
assert(!drawerText.includes("## Completed"), "主聊天不应展示 Board Completed 报告标题");
|
||||
await page.locator(`[data-page-ai-board-run-detail="board-run-${suffix}"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expectedPath) => {
|
||||
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return text.includes("Run detail") && text.includes("changed files") && text.includes(expectedPath) && text.includes("timeline");
|
||||
},
|
||||
filePath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
const restoredStorage = await page.evaluate(() => {
|
||||
const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:"));
|
||||
return JSON.stringify(keys.map((key) => ({ key, value: window.localStorage.getItem(key) || "" })));
|
||||
});
|
||||
if (!restoredStorage.includes(afterToken)) {
|
||||
throw new assert.AssertionError({
|
||||
message: `刷新后 localStorage 应保留 Board-first session/history: ${restoredStorage.slice(0, 1000)}`,
|
||||
});
|
||||
}
|
||||
await page.waitForFunction(
|
||||
(expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected),
|
||||
afterToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const screenshot = await saveScreenshot(page, "01-board-first-edit");
|
||||
const result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
rootUri,
|
||||
workspaceId,
|
||||
documentId,
|
||||
relativePath,
|
||||
filePath,
|
||||
beforeToken,
|
||||
afterToken,
|
||||
screenshot,
|
||||
capturedBoardRuns,
|
||||
capturedBoardRunResponses,
|
||||
drawerText,
|
||||
consoleErrors,
|
||||
finalDiskContent,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
await saveScreenshot(page, "failure").catch(() => undefined);
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "failure.json"),
|
||||
`${JSON.stringify({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
capturedBoardRuns,
|
||||
consoleErrors,
|
||||
root,
|
||||
rootUri,
|
||||
filePath,
|
||||
diskContent: fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "",
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task769-weknora-knowledge-settings-ui-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const UI_PATH = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function assertNotContains(content, needle, label) {
|
||||
assert(!content.includes(needle), `${label} 不应包含: ${needle}`);
|
||||
}
|
||||
|
||||
function assertOrdered(content, first, second, label) {
|
||||
const firstIndex = content.indexOf(first);
|
||||
const secondIndex = content.indexOf(second);
|
||||
assert(firstIndex >= 0, `${label} 缺少: ${first}`);
|
||||
assert(secondIndex >= 0, `${label} 缺少: ${second}`);
|
||||
assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应优先于 ${second}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const ui = read(UI_PATH);
|
||||
|
||||
for (const forbidden of [
|
||||
"打开 LightRAG",
|
||||
"相似度阈值",
|
||||
"相关度:",
|
||||
"本地 (Local)",
|
||||
"全局 (Global)",
|
||||
"朴素 (Naive)",
|
||||
"混合模式 (Mix)",
|
||||
"正在读取 LightRAG 状态",
|
||||
"无法连接 LightRAG 服务",
|
||||
]) {
|
||||
assertNotContains(ui, forbidden, "WeKnora knowledge settings UI");
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"WeKnora",
|
||||
"WeKnora hybrid",
|
||||
"data-testid=\"mnote-weknora-knowledge-settings-panel\"",
|
||||
"data-knowledge-rag-default-provider=\"weknora\"",
|
||||
"data-knowledge-rag-legacy-fallback-provider=\"lightrag\"",
|
||||
"data-knowledge-rag-source-registry=\"mnote\"",
|
||||
"data-knowledge-rag-provider-index=\"weknora-kb-chunk-index\"",
|
||||
"data-knowledge-rag-active-provider",
|
||||
"data-knowledge-rag-reference-boundary",
|
||||
"data-knowledge-rag-provider=\"weknora\"",
|
||||
"data-knowledge-rag-provider-id",
|
||||
"data-knowledge-rag-provider-ids",
|
||||
"data-knowledge-rag-knowledge-id",
|
||||
"data-knowledge-rag-chunk-count",
|
||||
"data-knowledge-rag-processing-status",
|
||||
"data-knowledge-rag-open-reference-mode",
|
||||
"data-knowledge-rag-delete-local-file-policy=\"preserve-local-file\"",
|
||||
"data-kb-rag-score-label=\"provider-ranking-score\"",
|
||||
"data-kb-rag-score-is-similarity-percent=\"false\"",
|
||||
"provider 排序分",
|
||||
"MNote source registry",
|
||||
"WeKnora KB chunk index",
|
||||
"provider=weknora",
|
||||
"provider ids",
|
||||
"knowledgeId",
|
||||
"chunkCount",
|
||||
"processingStatus",
|
||||
"定位降级",
|
||||
"删除 provider index 与 registry 映射,不删除本地文件",
|
||||
"打开知识库",
|
||||
"Provider 检索策略",
|
||||
"兼容阈值字段保留在请求层,不作为默认主控",
|
||||
]) {
|
||||
assertContains(ui, required, "WeKnora knowledge settings UI");
|
||||
}
|
||||
|
||||
assertOrdered(
|
||||
ui,
|
||||
"(weknora && weknora.dashboardUrl)",
|
||||
"status && (status.dashboardUrl || status.endpoint)",
|
||||
"dashboardUrl 优先级"
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [UI_PATH],
|
||||
assertions: {
|
||||
noDefaultLightRagCopy: true,
|
||||
noSimilarityThresholdPrimaryControl: true,
|
||||
noRelevancePercentCopy: true,
|
||||
weknoraHybridSearchCopy: true,
|
||||
providerSelectorCoverage: true,
|
||||
sourceRegistrySelectorCoverage: true,
|
||||
processingSelectorCoverage: true,
|
||||
citationOpenReferenceSelectorCoverage: true,
|
||||
providerRankingScoreCopy: true,
|
||||
sourceRegistryBoundaryCopy: true,
|
||||
deleteIndexDoesNotDeleteLocalFileCopy: true,
|
||||
dashboardUrlPriority: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,371 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const FIXTURE_DIR = process.env.MNOTE_TASK772_FIXTURE_DIR || "knowledge-rag-fixtures-7-68";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SKIP_PATH = path.join(OUTPUT_DIR, "skip.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK772_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK772_WEKNORA_POLL_MS || 5_000);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function envList(name) {
|
||||
return String(process.env[name] || "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function configuredKnowledgeBaseIds() {
|
||||
return envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS").concat(envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_ID"));
|
||||
}
|
||||
|
||||
function hasWeKnoraApiKey() {
|
||||
return Boolean(String(process.env.MNOTE_WEKNORA_API_KEY || process.env.WEKNORA_API_KEY || "").trim());
|
||||
}
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function skip(reason, details = {}) {
|
||||
const payload = {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
...details,
|
||||
};
|
||||
writeJson(SKIP_PATH, payload);
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_status", `knowledge-rag status 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function assertWeKnoraStatus(statusPayload, expectedKbIds) {
|
||||
const activeProvider = statusPayload?.providerConfig?.active || statusPayload?.provider;
|
||||
assert.equal(activeProvider, "weknora", `MNote knowledge provider 不是 weknora: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
|
||||
const serverKbIds = statusPayload?.providerConfig?.weknora?.knowledgeBaseIds || [];
|
||||
assert(serverKbIds.length > 0, `MNote 3000 未加载 WeKnora KB id: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
|
||||
for (const kbId of expectedKbIds) {
|
||||
assert(serverKbIds.includes(kbId), `MNote 3000 未加载当前 WeKnora KB id ${kbId}: ${JSON.stringify(serverKbIds)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
sources: [{ sourcePath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_http", `knowledge-rag ingest HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", `ingest provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
if (payload?.ok !== true || payload?.retryRequired) {
|
||||
throw failLayer("weknora_ingest_provider", "WeKnora ingest 未成功完成 provider upload", {
|
||||
response: payload,
|
||||
});
|
||||
}
|
||||
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
|
||||
const item = configured.find((source) => source.sourceRootRelativePath === sourcePath);
|
||||
assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(configured, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(item.provider, "weknora", `ingest source provider 应为 weknora: ${JSON.stringify(item, null, 2)}`);
|
||||
assert.equal(item.mappingStatus, "provider_mapped", `ingest 应写回 provider_mapped: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.upload?.attempted === true, `ingest 应真实调用 WeKnora upload: ${JSON.stringify(item, null, 2)}`);
|
||||
return { payload, item };
|
||||
}
|
||||
|
||||
async function search(context, query, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [sourcePath],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_search_http", `knowledge-rag search HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function referenceProviderIds(reference) {
|
||||
return {
|
||||
knowledgeBaseId: reference?.providerIds?.knowledgeBaseId || reference?.providerKnowledgeBaseId || "",
|
||||
knowledgeId: reference?.providerIds?.knowledgeId || reference?.providerKnowledgeId || "",
|
||||
chunkId: reference?.providerIds?.chunkId || reference?.providerChunkId || reference?.chunkId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function findTargetReference(searchPayload, ingestItem, marker, sourcePath) {
|
||||
const references = Array.isArray(searchPayload?.references) ? searchPayload.references : [];
|
||||
return references.find((reference) => {
|
||||
const ids = referenceProviderIds(reference);
|
||||
const quote = `${reference.displayQuote || ""}\n${reference.rawQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& ids.knowledgeId === ingestItem.providerKnowledgeId
|
||||
&& (reference.sourceRootRelativePath === sourcePath || quote.includes(marker));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function waitForSearchHit(context, marker, sourcePath, ingestItem) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
let lastError = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
try {
|
||||
lastPayload = await search(context, marker, sourcePath);
|
||||
const reference = findTargetReference(lastPayload, ingestItem, marker, sourcePath);
|
||||
if (reference) return { payload: lastPayload, reference };
|
||||
} catch (error) {
|
||||
lastError = {
|
||||
layer: error.layer || "search_poll",
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
};
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回目标 chunk 超时", {
|
||||
marker,
|
||||
sourcePath,
|
||||
providerKnowledgeId: ingestItem.providerKnowledgeId,
|
||||
lastError,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function rawWeKnoraChunkFromReference(reference) {
|
||||
const raw = reference?.reference;
|
||||
assert(raw && typeof raw === "object" && !Array.isArray(raw), `search reference 缺少原始 WeKnora chunk: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
return { provider: "weknora", ...raw };
|
||||
}
|
||||
|
||||
function assertMappedReference(reference, ingestItem, sourcePath, label) {
|
||||
assert.equal(reference?.provider, "weknora", `${label} provider 应为 weknora: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
const ids = referenceProviderIds(reference);
|
||||
assert(ids.knowledgeBaseId, `${label} 缺少 provider knowledgeBaseId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert(ids.knowledgeId, `${label} 缺少 provider knowledgeId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert(ids.chunkId, `${label} 缺少 provider chunkId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(ids.knowledgeId, ingestItem.providerKnowledgeId, `${label} provider knowledgeId 未保留 ingest 映射: ${JSON.stringify(ids)}`);
|
||||
assert.equal(reference.filePath ?? null, null, `${label} 不应把 WeKnora provider filename 伪造成 filePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(reference.sourceRootRelativePath, sourcePath, `${label} 应通过 registry 映射回 sourceRootRelativePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(reference.citationDiagnostics?.providerFilenameIsNotLocalPath, true, `${label} 应声明 provider filename 不是本地路径真相: ${JSON.stringify(reference.citationDiagnostics, null, 2)}`);
|
||||
assert.equal(reference.openAction?.params?.path, sourcePath, `${label} openAction 应使用 registry path,不使用 provider filename: ${JSON.stringify(reference.openAction, null, 2)}`);
|
||||
}
|
||||
|
||||
async function openReference(context, rawChunk) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/open-reference`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
reference: rawChunk,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_open_reference_http", `knowledge-rag open-reference HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.ok, true, `open-reference ok 应为 true: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(payload?.provider, "weknora", `open-reference provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const expectedKbIds = configuredKnowledgeBaseIds();
|
||||
if (expectedKbIds.length === 0 || !hasWeKnoraApiKey()) {
|
||||
console.warn(JSON.stringify({
|
||||
warning: "local_weknora_env_not_visible",
|
||||
message: "当前 smoke shell 未看到 WeKnora env;继续以 MNote 3000 status 和真实 API 调用为准。",
|
||||
requiredAny: {
|
||||
knowledgeBaseId: ["MNOTE_WEKNORA_KNOWLEDGE_BASE_ID", "MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS"],
|
||||
apiKey: ["MNOTE_WEKNORA_API_KEY", "WEKNORA_API_KEY"],
|
||||
},
|
||||
hasKnowledgeBaseId: expectedKbIds.length > 0,
|
||||
hasApiKey: hasWeKnoraApiKey(),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
const statusPayload = await status(context);
|
||||
try {
|
||||
assertWeKnoraStatus(statusPayload, expectedKbIds);
|
||||
} catch (error) {
|
||||
skip("mnote_weknora_provider_or_kb_not_configured", {
|
||||
assertion: error.message,
|
||||
providerConfig: statusPayload?.providerConfig || null,
|
||||
health: statusPayload?.health || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const marker = `task772 weknora e2e marker ${startedAt}`;
|
||||
const sourceName = `task772-weknora-e2e-${startedAt}.md`;
|
||||
const sourcePath = `${FIXTURE_DIR}/${sourceName}`;
|
||||
const absoluteSourcePath = path.join(ROOT_PATH, sourcePath);
|
||||
fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
absoluteSourcePath,
|
||||
[
|
||||
"# Task 772 WeKnora E2E",
|
||||
"",
|
||||
marker,
|
||||
"",
|
||||
"This markdown file is created by the MNote task772 smoke to verify real WeKnora ingest, search, and open-reference mapping.",
|
||||
"The provider filename must not become a forged local file path; MNote should map back through its source registry.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ingestResult = await ingest(context, sourcePath);
|
||||
const searchResult = await waitForSearchHit(context, marker, sourcePath, ingestResult.item);
|
||||
assert.equal(searchResult.payload?.provider, "weknora", `search provider 应为 weknora: ${JSON.stringify(searchResult.payload, null, 2).slice(0, 3000)}`);
|
||||
assertMappedReference(searchResult.reference, ingestResult.item, sourcePath, "search reference");
|
||||
|
||||
const rawChunk = rawWeKnoraChunkFromReference(searchResult.reference);
|
||||
const openPayload = await openReference(context, rawChunk);
|
||||
assertMappedReference(openPayload.reference, ingestResult.item, sourcePath, "open-reference");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
absoluteSourcePath,
|
||||
marker,
|
||||
provider: "weknora",
|
||||
providerKnowledgeBaseId: ingestResult.item.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: ingestResult.item.providerKnowledgeId,
|
||||
search: {
|
||||
resultCount: Array.isArray(searchResult.payload.results) ? searchResult.payload.results.length : 0,
|
||||
referenceCount: Array.isArray(searchResult.payload.references) ? searchResult.payload.references.length : 0,
|
||||
providerIds: referenceProviderIds(searchResult.reference),
|
||||
citationUrl: searchResult.reference.citationUrl || null,
|
||||
filePath: searchResult.reference.filePath ?? null,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath || null,
|
||||
},
|
||||
openReference: {
|
||||
providerIds: referenceProviderIds(openPayload.reference),
|
||||
citationUrl: openPayload.reference?.citationUrl || null,
|
||||
filePath: openPayload.reference?.filePath ?? null,
|
||||
sourceRootRelativePath: openPayload.reference?.sourceRootRelativePath || null,
|
||||
openAction: openPayload.reference?.openAction || null,
|
||||
},
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "weknora-section-context-smoke-result.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
|
||||
function readTask772Result() {
|
||||
if (!fs.existsSync(TASK772_RESULT_PATH)) {
|
||||
throw new Error(`缺少 task772 结果,请先运行 node scripts/task772-weknora-ingest-search-open-reference-e2e.js: ${TASK772_RESULT_PATH}`);
|
||||
}
|
||||
const payload = JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8"));
|
||||
assert.equal(payload.ok, true, `task772 结果不是 ok=true: ${JSON.stringify(payload, null, 2).slice(0, 1000)}`);
|
||||
const sourcePath = payload.sourcePath;
|
||||
const providerKnowledgeId = payload.providerKnowledgeId;
|
||||
const providerKnowledgeBaseId = payload.providerKnowledgeBaseId;
|
||||
const providerChunkId = payload.search?.providerIds?.chunkId || payload.openReference?.providerIds?.chunkId;
|
||||
assert(sourcePath, "task772 结果缺少 sourcePath");
|
||||
assert(providerKnowledgeId, "task772 结果缺少 providerKnowledgeId");
|
||||
assert(providerChunkId, "task772 结果缺少 provider chunkId");
|
||||
return {
|
||||
sourcePath,
|
||||
providerKnowledgeId,
|
||||
providerKnowledgeBaseId,
|
||||
providerChunkId,
|
||||
marker: payload.marker,
|
||||
};
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: "mnote-e2e",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function sectionContext(context, body) {
|
||||
const response = await context.post(`${BASE_URL}/api/knowledge-rag/section-context`, {
|
||||
data: {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
maxChars: 4000,
|
||||
...body,
|
||||
},
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
assert(response.ok(), `section-context HTTP ${response.status()}: ${text.slice(0, 1000)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function assertProviderChunkPayload(payload, expected) {
|
||||
assert.equal(payload.provider, "weknora", `provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
|
||||
assert.equal(payload.sidecarRead, false, "WeKnora section-context 不应读取 LightRAG sidecar");
|
||||
assert.equal(payload.providerChunkFetch?.attempted, true, "应尝试 WeKnora provider chunk fetch");
|
||||
assert.equal(payload.providerChunkFetch?.ok, true, `provider chunk fetch 应成功: ${JSON.stringify(payload.providerChunkFetch, null, 2)}`);
|
||||
assert(Array.isArray(payload.blocks) && payload.blocks.length > 0, "section-context 应返回 provider chunk blocks");
|
||||
assert(Array.isArray(payload.chunks) && payload.chunks.length > 0, "section-context 应返回 agent chunks");
|
||||
assert(String(payload.text || "").includes(expected.marker), "section-context text 应包含 task772 marker");
|
||||
const first = payload.blocks[0];
|
||||
assert(first.providerChunk, `block 应保留 providerChunk metadata: ${JSON.stringify(first, null, 2).slice(0, 1000)}`);
|
||||
assert.equal(first.providerChunk.knowledgeId, expected.providerKnowledgeId, "providerChunk knowledgeId 应保留");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const expected = readTask772Result();
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
let result = { ok: false, task: "task777-weknora-section-context-smoke", expected };
|
||||
try {
|
||||
await signIn(context);
|
||||
const byChunk = await sectionContext(context, {
|
||||
sourcePath: expected.sourcePath,
|
||||
providerKnowledgeId: expected.providerKnowledgeId,
|
||||
providerKnowledgeBaseId: expected.providerKnowledgeBaseId,
|
||||
providerChunkId: expected.providerChunkId,
|
||||
});
|
||||
assertProviderChunkPayload(byChunk, expected);
|
||||
const byKnowledge = await sectionContext(context, {
|
||||
sourcePath: expected.sourcePath,
|
||||
providerKnowledgeId: expected.providerKnowledgeId,
|
||||
providerKnowledgeBaseId: expected.providerKnowledgeBaseId,
|
||||
});
|
||||
assertProviderChunkPayload(byKnowledge, expected);
|
||||
result = {
|
||||
...result,
|
||||
ok: true,
|
||||
byChunk: {
|
||||
providerChunkFetch: byChunk.providerChunkFetch,
|
||||
providerChunkId: byChunk.providerChunkId,
|
||||
returnedBlocks: byChunk.blocks.length,
|
||||
returnedChunks: byChunk.chunks.length,
|
||||
degradedReason: byChunk.degradedReason,
|
||||
},
|
||||
byKnowledge: {
|
||||
providerChunkFetch: byKnowledge.providerChunkFetch,
|
||||
returnedBlocks: byKnowledge.blocks.length,
|
||||
returnedChunks: byKnowledge.chunks.length,
|
||||
degradedReason: byKnowledge.degradedReason,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const FIXTURE_DIR = process.env.MNOTE_TASK781_FIXTURE_DIR || "knowledge-rag-fixtures-7-68";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task781-knowledge-bases-registry-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK781_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK781_WEKNORA_POLL_MS || 5_000);
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function assertKnowledgeBases(payload) {
|
||||
assert.equal(payload?.provider, "weknora", `status provider 应为 weknora: ${JSON.stringify(payload?.providerConfig || payload, null, 2).slice(0, 1000)}`);
|
||||
assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "status 应返回 mnote_knowledge_bases registry schema");
|
||||
assert.equal(payload?.registry?.schema, "mnote.knowledge_rag.source_registry.v1", "status 应保留旧 source registry schema");
|
||||
assert.equal(payload?.providerConfig?.weknora?.knowledgeBaseRegistry, "mnote_knowledge_bases", "providerConfig 应暴露 KB registry 边界");
|
||||
assert.equal(payload?.providerConfig?.weknora?.sourceRegistry, "mnote_knowledge_sources", "providerConfig 应暴露 source registry 边界");
|
||||
const bases = payload?.knowledgeBases?.bases || [];
|
||||
assert(Array.isArray(bases) && bases.length > 0, `knowledgeBases.bases 应至少包含默认 WeKnora KB: ${JSON.stringify(payload?.knowledgeBases, null, 2)}`);
|
||||
const base = bases.find((item) => item.provider === "weknora" && item.providerKbId);
|
||||
assert(base, `缺少 provider-neutral WeKnora KB: ${JSON.stringify(bases, null, 2)}`);
|
||||
assert.equal(base.defaultToolEnabled, true, "默认 WeKnora KB 应开启 tool 可见性");
|
||||
return base;
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
sources: [{ sourcePath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_http", `ingest HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
|
||||
assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "ingest 应返回 knowledgeBases registry");
|
||||
const item = (payload.configuredSources || []).find((source) => source.sourceRootRelativePath === sourcePath);
|
||||
assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(payload.configuredSources, null, 2).slice(0, 1000)}`);
|
||||
assert.equal(item.mappingStatus, "provider_mapped", `ingest 应 provider_mapped: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`);
|
||||
const registryEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === sourcePath);
|
||||
assert(registryEntry, "source registry 应写入目标 source");
|
||||
assert.equal(registryEntry.provider, "weknora", `source registry 应写 provider: ${JSON.stringify(registryEntry, null, 2)}`);
|
||||
assert.equal(registryEntry.providerKnowledgeBaseId, item.providerKnowledgeBaseId, "source registry 应写 providerKnowledgeBaseId");
|
||||
assert.equal(registryEntry.providerKnowledgeId, item.providerKnowledgeId, "source registry 应写 providerKnowledgeId");
|
||||
assert.equal(registryEntry.providerSourceId, item.providerKnowledgeId, "source registry 应写 providerSourceId");
|
||||
assert(registryEntry.lightRagDocId, "旧 lightRagDocId 兼容字段仍应保留 provider knowledge id");
|
||||
return { payload, item, registryEntry };
|
||||
}
|
||||
|
||||
async function search(context, marker, sourcePath) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: marker,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [sourcePath],
|
||||
});
|
||||
assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
lastPayload = result.payload;
|
||||
const hit = (lastPayload.references || []).find((reference) => {
|
||||
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora" && (reference.sourceRootRelativePath === sourcePath || quote.includes(marker));
|
||||
});
|
||||
if (hit) return { payload: lastPayload, reference: hit };
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回 task781 source 超时", { marker, sourcePath, lastPayload });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
const beforeStatus = await status(context);
|
||||
const baseBefore = assertKnowledgeBases(beforeStatus);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const marker = `task781 knowledge bases registry marker ${startedAt}`;
|
||||
const sourcePath = `${FIXTURE_DIR}/task781-knowledge-base-registry-${startedAt}.md`;
|
||||
const absoluteSourcePath = path.join(ROOT_PATH, sourcePath);
|
||||
fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
absoluteSourcePath,
|
||||
[
|
||||
"# Task 781 Knowledge Base Registry",
|
||||
"",
|
||||
marker,
|
||||
"",
|
||||
"This file verifies MNote provider-neutral knowledge base and source registry boundaries.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ingestResult = await ingest(context, sourcePath);
|
||||
const searchResult = await search(context, marker, sourcePath);
|
||||
const afterStatus = await status(context);
|
||||
const baseAfter = assertKnowledgeBases(afterStatus);
|
||||
|
||||
const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json");
|
||||
assert(fs.existsSync(persistedRegistryPath), `应持久化 mnote_knowledge_bases registry: ${persistedRegistryPath}`);
|
||||
const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8"));
|
||||
assert.equal(persistedRegistry.schema, "mnote.knowledge_bases.registry.v1", "持久化 KB registry schema 不正确");
|
||||
assert((persistedRegistry.bases || []).some((base) => base.provider === "weknora" && base.providerKbId), "持久化 KB registry 缺少 WeKnora base");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task781-knowledge-bases-registry-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
absoluteSourcePath,
|
||||
marker,
|
||||
knowledgeBase: {
|
||||
before: {
|
||||
baseId: baseBefore.baseId,
|
||||
provider: baseBefore.provider,
|
||||
providerKbId: baseBefore.providerKbId,
|
||||
defaultToolEnabled: baseBefore.defaultToolEnabled,
|
||||
},
|
||||
after: {
|
||||
baseId: baseAfter.baseId,
|
||||
provider: baseAfter.provider,
|
||||
providerKbId: baseAfter.providerKbId,
|
||||
defaultToolEnabled: baseAfter.defaultToolEnabled,
|
||||
sourceCount: baseAfter.sourceCount,
|
||||
},
|
||||
persistedRegistryPath,
|
||||
},
|
||||
sourceRegistry: {
|
||||
provider: ingestResult.registryEntry.provider,
|
||||
providerStatus: ingestResult.registryEntry.providerStatus,
|
||||
providerKnowledgeBaseId: ingestResult.registryEntry.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: ingestResult.registryEntry.providerKnowledgeId,
|
||||
providerSourceId: ingestResult.registryEntry.providerSourceId,
|
||||
lightRagDocIdCompat: ingestResult.registryEntry.lightRagDocId,
|
||||
},
|
||||
search: {
|
||||
provider: searchResult.payload.provider,
|
||||
referenceProvider: searchResult.reference.provider,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
|
||||
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
|
||||
providerChunkId: searchResult.reference.providerChunkId,
|
||||
},
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task781-knowledge-bases-registry-smoke",
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task783-weknora-kb-settings-ui-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
|
||||
const SIDEBAR_RUNTIME = "rust/crates/mnote-web/browser/sidebar-tree-runtime.js";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function assertOrdered(content, first, second, label) {
|
||||
const firstIndex = content.indexOf(first);
|
||||
const secondIndex = content.indexOf(second);
|
||||
assert(firstIndex >= 0, `${label} 缺少: ${first}`);
|
||||
assert(secondIndex >= 0, `${label} 缺少: ${second}`);
|
||||
assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应早于 ${second}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const settings = read(SETTINGS_RUNTIME);
|
||||
const sidebar = read(SIDEBAR_RUNTIME);
|
||||
|
||||
for (const required of [
|
||||
"data-mnote-weknora-page-replacement=\"true\"",
|
||||
"mnote-weknora-kb-page",
|
||||
"data-testid=\"mnote-weknora-kb-list-pane\"",
|
||||
"data-testid=\"mnote-weknora-kb-card-list\"",
|
||||
"data-knowledge-rag-action=\"select-kb\"",
|
||||
"data-testid=\"mnote-weknora-kb-detail-pane\"",
|
||||
"data-testid=\"mnote-weknora-kb-detail-hero\"",
|
||||
"data-weknora-document-list-view=\"true\"",
|
||||
"知识库列表",
|
||||
"创建知识库",
|
||||
"上传/索引到 WeKnora",
|
||||
"data-testid=\"mnote-weknora-kb-summary\"",
|
||||
"data-knowledge-rag-kb-provider=\"weknora\"",
|
||||
"data-knowledge-rag-kb-id",
|
||||
"data-knowledge-rag-kb-ids",
|
||||
"data-knowledge-rag-processing-count",
|
||||
"data-knowledge-rag-source-picker=\"mnote-filetree\"",
|
||||
"data-knowledge-rag-source-kind=\"local-file-or-folder\"",
|
||||
"本地文件或文件夹,例如 docs/book.pdf / docs",
|
||||
"providerKnowledgeBaseId",
|
||||
"providerKnowledgeId",
|
||||
"provider_mapped",
|
||||
"data-knowledge-rag-kb-id",
|
||||
"data-knowledge-rag-chunk-id",
|
||||
"data-kb-rag-search-result=\"true\"",
|
||||
"data-knowledge-rag-action=\"open-source-reference\"",
|
||||
"data-knowledge-rag-open-reference-mode=\"search-result-open-reference\"",
|
||||
"data-mnote-knowledge-rag-open-reference-mode",
|
||||
"source-registry-reference",
|
||||
"openPrimaryDocument",
|
||||
"resourcePath",
|
||||
"删除 provider index 与 registry 映射,不删除本地文件",
|
||||
]) {
|
||||
assertContains(settings, required, SETTINGS_RUNTIME);
|
||||
}
|
||||
|
||||
assertContains(sidebar, "openKnowledgeRagSourceReference", SIDEBAR_RUNTIME);
|
||||
assertContains(sidebar, "knowledgeRagActionName === 'open-source-reference'", SIDEBAR_RUNTIME);
|
||||
assertContains(sidebar, "knowledgeRagActionName === 'select-kb'", SIDEBAR_RUNTIME);
|
||||
assertContains(sidebar, "knowledgeRagActionName === 'focus-create-kb'", SIDEBAR_RUNTIME);
|
||||
|
||||
assertOrdered(
|
||||
settings,
|
||||
"knowledgeRagKbSummaryHtml(status, entries, providerDocs)",
|
||||
"<div><span>Provider</span><code>",
|
||||
"KB summary 应显示在 provider 详情前",
|
||||
);
|
||||
assertOrdered(
|
||||
settings,
|
||||
"data-knowledge-rag-action=\"open-source-reference\"",
|
||||
"data-knowledge-rag-action=\"delete-source\"",
|
||||
"source row 应先提供打开引用,再提供删除索引",
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [SETTINGS_RUNTIME, SIDEBAR_RUNTIME],
|
||||
assertions: {
|
||||
kbSummaryVisible: true,
|
||||
weknoraPageReplacementVisible: true,
|
||||
weknoraKbListPaneVisible: true,
|
||||
weknoraKbDetailPaneVisible: true,
|
||||
weknoraDocumentListViewVisible: true,
|
||||
fileTreePickerBoundary: true,
|
||||
processingStateVisible: true,
|
||||
searchResultOpenReference: true,
|
||||
sourceRowOpenReference: true,
|
||||
deletePreservesLocalFileCopy: true,
|
||||
noBackendKnowledgeRagEditRequired: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task784-weknora-kb-settings-browser-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-settings.png");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function loadTask772Result() {
|
||||
assert(fs.existsSync(TASK772_RESULT_PATH), `缺少 task772 真实入库结果: ${TASK772_RESULT_PATH}`);
|
||||
return JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8"));
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const fixture = loadTask772Result();
|
||||
assert.equal(fixture.provider, "weknora", `task772 provider 不是 weknora: ${JSON.stringify(fixture, null, 2).slice(0, 1200)}`);
|
||||
assert(fixture.rootUri && fixture.workspaceId && fixture.sourcePath && fixture.marker, "task772 结果缺少 rootUri/workspaceId/sourcePath/marker");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await signIn(context);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fixture.rootUri);
|
||||
url.searchParams.set("workspaceId", fixture.workspaceId);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
|
||||
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const sourceRow = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first();
|
||||
await sourceRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await sourceRow.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await sourceRow.locator('[data-knowledge-rag-action="delete-source"][data-knowledge-rag-delete-local-file-policy="preserve-local-file"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]');
|
||||
await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids");
|
||||
assert(String(kbIds || "").includes(fixture.providerKnowledgeBaseId), `KB summary 未包含 task772 KB id: ${kbIds}`);
|
||||
|
||||
await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await panel.locator('[data-kb-rag-search-input]').fill(fixture.marker, { timeout: UI_TIMEOUT_MS });
|
||||
await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const searchResult = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first();
|
||||
await searchResult.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await searchResult.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: fixture.rootUri,
|
||||
workspaceId: fixture.workspaceId,
|
||||
sourcePath: fixture.sourcePath,
|
||||
providerKnowledgeBaseId: fixture.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: fixture.providerKnowledgeId,
|
||||
kbSummaryVisible: true,
|
||||
sourceRowVisible: true,
|
||||
searchResultVisible: true,
|
||||
openReferenceControlsVisible: true,
|
||||
deletePreserveLocalFileControlVisible: true,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "failure.json"),
|
||||
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.error(error.stack || error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task785-weknora-create-kb-folder-ingest-search-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK785_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK785_WEKNORA_POLL_MS || 5_000);
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function createKnowledgeBase(context, name, description) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
name,
|
||||
description,
|
||||
defaultToolEnabled: true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora");
|
||||
assert.equal(payload?.schema, "mnote.knowledge_rag.create_knowledge_base_result.v1", "create KB schema 不正确");
|
||||
assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
|
||||
assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora");
|
||||
assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId");
|
||||
assert.equal(payload?.knowledgeBase?.defaultToolEnabled, true, "新 KB 应默认启用 tool");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ingestFolder(context, folderPath, providerKnowledgeBaseId) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
providerKnowledgeBaseId,
|
||||
sources: [{ sourcePath: folderPath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
|
||||
const configured = payload.configuredSources || [];
|
||||
assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1200)}`);
|
||||
const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId);
|
||||
assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`);
|
||||
const registryEntries = payload.registry?.entries || [];
|
||||
const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`));
|
||||
assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source");
|
||||
assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora");
|
||||
assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id");
|
||||
return { payload, configured, mapped, folderEntries };
|
||||
}
|
||||
|
||||
async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
providerKnowledgeBaseId,
|
||||
sourcePaths: [folderPath],
|
||||
});
|
||||
assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
lastPayload = result.payload;
|
||||
const hit = (lastPayload.references || []).find((reference) => {
|
||||
const pathText = String(reference.sourceRootRelativePath || "");
|
||||
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& reference.providerKnowledgeBaseId === providerKnowledgeBaseId
|
||||
&& (pathText.startsWith(`${folderPath}/`) || quote.includes(query));
|
||||
});
|
||||
if (hit) return { payload: lastPayload, reference: hit };
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", {
|
||||
query,
|
||||
folderPath,
|
||||
providerKnowledgeBaseId,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
const startedAt = Date.now();
|
||||
const markerA = `task785 create kb folder marker alpha ${startedAt}`;
|
||||
const markerB = `task785 create kb folder marker beta ${startedAt}`;
|
||||
const folderPath = `knowledge-rag-fixtures-7-68/task785-folder-${startedAt}`;
|
||||
const absoluteFolderPath = path.join(ROOT_PATH, folderPath);
|
||||
fs.mkdirSync(absoluteFolderPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(absoluteFolderPath, "alpha.md"), `# Task 785 Alpha\n\n${markerA}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(absoluteFolderPath, "beta.md"), `# Task 785 Beta\n\n${markerB}\n`, "utf8");
|
||||
|
||||
const name = `task785-weknora-kb-${startedAt}`;
|
||||
const createResult = await createKnowledgeBase(context, name, "MNote task785 temporary KB created through MNote facade");
|
||||
const providerKnowledgeBaseId = createResult.providerKnowledgeBaseId;
|
||||
const ingestResult = await ingestFolder(context, folderPath, providerKnowledgeBaseId);
|
||||
const searchResult = await searchUntilHit(context, markerA, folderPath, providerKnowledgeBaseId);
|
||||
const afterStatus = await status(context);
|
||||
const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId);
|
||||
assert(statusBase, "status knowledgeBases 应包含新建 KB");
|
||||
assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`);
|
||||
|
||||
const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json");
|
||||
const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8"));
|
||||
assert((persistedRegistry.bases || []).some((base) => base.providerKbId === providerKnowledgeBaseId), "持久化 KB registry 应包含新建 KB");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task785-weknora-create-kb-folder-ingest-search-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
folderPath,
|
||||
absoluteFolderPath,
|
||||
markers: [markerA, markerB],
|
||||
knowledgeBase: {
|
||||
name,
|
||||
providerKnowledgeBaseId,
|
||||
baseId: createResult.knowledgeBase?.baseId,
|
||||
defaultToolEnabled: createResult.knowledgeBase?.defaultToolEnabled,
|
||||
sourceCount: statusBase.sourceCount,
|
||||
status: statusBase.status,
|
||||
},
|
||||
ingest: {
|
||||
configuredCount: ingestResult.configured.length,
|
||||
mappedCount: ingestResult.mapped.length,
|
||||
sourcePaths: ingestResult.mapped.map((item) => item.sourceRootRelativePath),
|
||||
},
|
||||
search: {
|
||||
provider: searchResult.payload.provider,
|
||||
referenceProvider: searchResult.reference.provider,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
|
||||
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
|
||||
providerChunkId: searchResult.reference.providerChunkId,
|
||||
},
|
||||
persistedRegistryPath,
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task785-weknora-create-kb-folder-ingest-search-smoke",
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task787-weknora-default-lightrag-legacy-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_TASK787_STATUS_TIMEOUT_MS || 15_000);
|
||||
|
||||
const REQUIRED_STATIC_FILES = [
|
||||
"rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js",
|
||||
"rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js",
|
||||
"scripts/TESTING_REFERENCE.md",
|
||||
];
|
||||
|
||||
const TASK_SMOKE_PATTERN = /^task(?:53|54|76|78)\d.*\.js$/;
|
||||
const SELF_PATH = "scripts/task787-weknora-default-lightrag-legacy-smoke.js";
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function listTaskSmokeFiles() {
|
||||
return fs.readdirSync(path.join(ROOT, "scripts"), { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && TASK_SMOKE_PATTERN.test(entry.name))
|
||||
.map((entry) => `scripts/${entry.name}`)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function extractWindow(line, start, length) {
|
||||
const from = Math.max(0, start - 90);
|
||||
const to = Math.min(line.length, start + length + 90);
|
||||
return line.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function allowedLegacyContext(contextBlock, occurrenceWindow) {
|
||||
const lowerBlock = contextBlock.toLowerCase();
|
||||
const lowerWindow = occurrenceWindow.toLowerCase();
|
||||
const legacyMarkers = [
|
||||
"legacy",
|
||||
"fallback",
|
||||
"history",
|
||||
"historical",
|
||||
"debug",
|
||||
"compat",
|
||||
"retired",
|
||||
"assertnotcontains",
|
||||
"assertnotincludes",
|
||||
"forbidden",
|
||||
"notcontains",
|
||||
"notincludes",
|
||||
"not contain",
|
||||
"does not include",
|
||||
"no lightrag",
|
||||
"nolightrag",
|
||||
"nodefaultlightrag",
|
||||
"without lightrag",
|
||||
"bypass",
|
||||
"bypasses",
|
||||
"skip",
|
||||
"旧",
|
||||
"历史",
|
||||
"兼容",
|
||||
"调试",
|
||||
"退役",
|
||||
"软归档",
|
||||
"不应包含",
|
||||
"不作为",
|
||||
"不再",
|
||||
"不直接",
|
||||
"不删除",
|
||||
"不走",
|
||||
"禁止",
|
||||
"绕过",
|
||||
"跳过",
|
||||
"移除",
|
||||
"缺少",
|
||||
];
|
||||
return legacyMarkers.some((marker) => lowerWindow.includes(marker) || lowerBlock.includes(marker));
|
||||
}
|
||||
|
||||
function ignoredTechnicalField(line) {
|
||||
return /\blightRag(?:DocId|FilePath|Status)\b/.test(line)
|
||||
|| /lightrag-source-registry\.json/.test(line);
|
||||
}
|
||||
|
||||
function ignoredLegacyAssetName(line) {
|
||||
return /task\d+-lightrag-[\w-]+/.test(line);
|
||||
}
|
||||
|
||||
function ignoredNegativeAssertionName(line) {
|
||||
return /\b(?:uiNoDefault|ingestNo|deleteNo|sectionContextNo|noDefault)LightRag[A-Za-z0-9_]*\b/.test(line);
|
||||
}
|
||||
|
||||
function classifyActiveLightRagContext(contextBlock, occurrenceWindow) {
|
||||
const normalized = `${contextBlock}\n${occurrenceWindow}`.toLowerCase();
|
||||
const reasons = [];
|
||||
const activeDefaultPatterns = [
|
||||
/默认\s*provider/i,
|
||||
/唯一默认/i,
|
||||
/主线\s*provider/i,
|
||||
/default\s*provider/i,
|
||||
/primary\s*provider/i,
|
||||
/mainline\s*provider/i,
|
||||
/only\s*default/i,
|
||||
];
|
||||
if (activeDefaultPatterns.some((pattern) => pattern.test(contextBlock) || pattern.test(occurrenceWindow))) {
|
||||
reasons.push("forbidden_default_or_mainline_provider_copy");
|
||||
}
|
||||
const activeSurfacePatterns = [
|
||||
/资料库/,
|
||||
/知识库/,
|
||||
/问答/,
|
||||
/检索/,
|
||||
/搜索/,
|
||||
/引用/,
|
||||
/入库/,
|
||||
/dashboard/,
|
||||
/health/,
|
||||
/status/,
|
||||
/source[-_ ]?registry/,
|
||||
/search\s*provider/,
|
||||
/references?/,
|
||||
/retrieval/,
|
||||
/smoke/,
|
||||
];
|
||||
if (activeSurfacePatterns.some((pattern) => pattern.test(normalized))) {
|
||||
reasons.push("active_lightrag_surface_without_legacy_marker");
|
||||
}
|
||||
if (reasons.length === 0) {
|
||||
reasons.push("unqualified_lightrag_reference_without_legacy_marker");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function scanStaticFiles(files) {
|
||||
const violations = [];
|
||||
const scannedFiles = [];
|
||||
for (const relativePath of files) {
|
||||
const absolutePath = path.join(ROOT, relativePath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
violations.push({
|
||||
file: relativePath,
|
||||
line: 0,
|
||||
reason: "static_file_missing",
|
||||
context: "required static/smoke baseline file is missing",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
scannedFiles.push(relativePath);
|
||||
const content = fs.readFileSync(absolutePath, "utf8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
lines.forEach((line, index) => {
|
||||
const matches = Array.from(line.matchAll(/lightrag/gi));
|
||||
for (const match of matches) {
|
||||
if (ignoredTechnicalField(line) || ignoredLegacyAssetName(line) || ignoredNegativeAssertionName(line)) {
|
||||
continue;
|
||||
}
|
||||
const occurrenceWindow = extractWindow(line, match.index || 0, match[0].length);
|
||||
const contextBlock = lines.slice(Math.max(0, index - 8), Math.min(lines.length, index + 5)).join("\n");
|
||||
if (allowedLegacyContext(contextBlock, occurrenceWindow)) {
|
||||
continue;
|
||||
}
|
||||
violations.push({
|
||||
file: relativePath,
|
||||
line: index + 1,
|
||||
reason: classifyActiveLightRagContext(contextBlock, occurrenceWindow),
|
||||
context: occurrenceWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return { scannedFiles, violations };
|
||||
}
|
||||
|
||||
async function fetchStatusProviderConfig() {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const url = `${BASE_URL}/api/knowledge-rag/status`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
layer: "status_api_invalid_json",
|
||||
url,
|
||||
status: response.status,
|
||||
error: error.message,
|
||||
rawText: text.slice(0, 1000),
|
||||
providerConfig: null,
|
||||
};
|
||||
}
|
||||
const providerConfig = payload && typeof payload.providerConfig === "object" ? payload.providerConfig : null;
|
||||
const active = providerConfig && providerConfig.active;
|
||||
const defaultProvider = providerConfig && providerConfig.default;
|
||||
const provider = payload && payload.provider;
|
||||
const ok = response.ok && providerConfig && active === "weknora" && defaultProvider === "weknora";
|
||||
return {
|
||||
ok,
|
||||
layer: response.ok ? (ok ? "status_provider_config_ok" : "status_provider_config_mismatch") : "status_api_http_error",
|
||||
url,
|
||||
status: response.status,
|
||||
provider,
|
||||
providerConfig,
|
||||
responseSample: ok ? undefined : payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
layer: error && error.name === "AbortError" ? "status_api_timeout" : "status_api_unavailable",
|
||||
url,
|
||||
error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error),
|
||||
providerConfig: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const staticFiles = uniqueSorted([...REQUIRED_STATIC_FILES, ...listTaskSmokeFiles()]);
|
||||
const staticScan = scanStaticFiles(staticFiles.filter((relativePath) => relativePath !== SELF_PATH));
|
||||
const statusCheck = await fetchStatusProviderConfig();
|
||||
const ok = staticScan.violations.length === 0 && statusCheck.ok;
|
||||
const payload = {
|
||||
ok,
|
||||
task: "task787-weknora-default-lightrag-legacy-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
checkedAt: new Date().toISOString(),
|
||||
scannedFiles: staticScan.scannedFiles,
|
||||
violations: staticScan.violations,
|
||||
statusProviderConfig: statusCheck,
|
||||
};
|
||||
if (ok) {
|
||||
writeJson(RESULT_PATH, payload);
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task787-weknora-default-lightrag-legacy-smoke",
|
||||
layer: "unexpected",
|
||||
error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error),
|
||||
baseUrl: BASE_URL,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium, request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task788-weknora-kb-folder-browser-flow-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-folder-browser-flow.png");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK788_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK788_WEKNORA_POLL_MS || 5_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signInRequest(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function signInBrowser(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `浏览器上下文登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_status_http", `status HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function createKnowledgeBase(context, name, description) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
name,
|
||||
description,
|
||||
defaultToolEnabled: true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora");
|
||||
assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
|
||||
assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora");
|
||||
assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ingestFolder(context, folderPath, providerKnowledgeBaseId) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
providerKnowledgeBaseId,
|
||||
sources: [{ sourcePath: folderPath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
|
||||
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
|
||||
assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`);
|
||||
const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId);
|
||||
assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 2000)}`);
|
||||
const registryEntries = payload.registry?.entries || [];
|
||||
const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`));
|
||||
assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source");
|
||||
assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora");
|
||||
assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id");
|
||||
return { payload, configured, mapped, folderEntries };
|
||||
}
|
||||
|
||||
async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
providerKnowledgeBaseId,
|
||||
sourcePaths: [folderPath],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_search_http", `search HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
lastPayload = result.payload;
|
||||
const hit = (lastPayload.references || []).find((reference) => {
|
||||
const pathText = String(reference.sourceRootRelativePath || "");
|
||||
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& reference.providerKnowledgeBaseId === providerKnowledgeBaseId
|
||||
&& (pathText.startsWith(`${folderPath}/`) || quote.includes(query));
|
||||
});
|
||||
if (hit) return { payload: lastPayload, reference: hit };
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", {
|
||||
query,
|
||||
folderPath,
|
||||
providerKnowledgeBaseId,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function createFolderFixture(startedAt) {
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
const marker = `task788 weknora kb folder browser marker ${startedAt}`;
|
||||
const markers = [`${marker} alpha`, `${marker} beta`];
|
||||
const folderPath = `knowledge-rag-fixtures-7-68/task788-folder-${startedAt}`;
|
||||
const absoluteFolderPath = path.join(ROOT_PATH, folderPath);
|
||||
const sourcePaths = [`${folderPath}/alpha.md`, `${folderPath}/beta.md`];
|
||||
fs.mkdirSync(absoluteFolderPath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(absoluteFolderPath, "alpha.md"),
|
||||
`# Task 788 Alpha\n\n${markers[0]}\n\nWeKnora folder browser flow alpha note.\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(absoluteFolderPath, "beta.md"),
|
||||
`# Task 788 Beta\n\n${markers[1]}\n\nWeKnora folder browser flow beta note.\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { folderPath, absoluteFolderPath, sourcePaths, marker, markers };
|
||||
}
|
||||
|
||||
async function verifyInBrowser(fixture, name) {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await signInBrowser(context);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", ROOT_URI);
|
||||
url.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const knowledgeEntry = page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]');
|
||||
assert.equal(await knowledgeEntry.getAttribute("href"), "/knowledge", "主知识库入口应打开 WeKnora 原生 host");
|
||||
assert.equal(await knowledgeEntry.getAttribute("data-mnote-action"), "open-knowledge-host", "主知识库入口不应再打开旧设置壳");
|
||||
await page.goto(`${BASE_URL}/debug/knowledge-rag`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
|
||||
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider");
|
||||
|
||||
await panel.locator('[data-testid="mnote-weknora-create-kb-name"]').fill(name);
|
||||
await panel.locator('[data-testid="mnote-weknora-create-kb-description"]').fill("MNote task788 temporary KB created through browser settings UI");
|
||||
await panel.locator('[data-knowledge-rag-action="create-kb"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const kbSelect = panel.locator('[data-testid="mnote-weknora-kb-select"]');
|
||||
await page.waitForFunction(
|
||||
(expectedName) => {
|
||||
const status = document.querySelector('[data-testid="mnote-weknora-create-kb-status"]');
|
||||
if (!(status instanceof HTMLElement) || status.getAttribute("data-status") !== "done") return false;
|
||||
const select = document.querySelector('[data-testid="mnote-weknora-kb-select"]');
|
||||
if (!(select instanceof HTMLSelectElement) || !select.value.trim()) return false;
|
||||
const selected = select.options[select.selectedIndex];
|
||||
return selected && selected.textContent.includes(expectedName);
|
||||
},
|
||||
name,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const providerKnowledgeBaseId = await kbSelect.inputValue();
|
||||
assert(providerKnowledgeBaseId, "UI 创建 KB 后应选中新 providerKnowledgeBaseId");
|
||||
|
||||
const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first();
|
||||
await firstSourceInput.fill(fixture.folderPath);
|
||||
await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => {
|
||||
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
|
||||
await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS });
|
||||
await row.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return sourcePath;
|
||||
}));
|
||||
|
||||
const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]');
|
||||
await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids");
|
||||
const sourceRowsForKb = await panel.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).count();
|
||||
assert(sourceRowsForKb >= 2, `source rows 未反映新建 KB id: ${providerKnowledgeBaseId}`);
|
||||
|
||||
await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await panel.locator('[data-kb-rag-search-input]').fill(fixture.markers[0]);
|
||||
let searchResultSourcePath = null;
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const result = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).first();
|
||||
try {
|
||||
await result.waitFor({ state: "visible", timeout: POLL_INTERVAL_MS });
|
||||
searchResultSourcePath = await result.getAttribute("data-knowledge-rag-source-path");
|
||||
break;
|
||||
} catch {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
assert(searchResultSourcePath, `UI 检索未展示新 KB ${providerKnowledgeBaseId} 的结果`);
|
||||
await panel.locator('[data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
return {
|
||||
providerKnowledgeBaseId,
|
||||
kbSummaryVisible: true,
|
||||
kbSummaryConfiguredKbIds: String(kbIds || "").split(",").filter(Boolean),
|
||||
sourceRowsForKb,
|
||||
sourceRowsVisible: sourceRows,
|
||||
searchResultVisible: true,
|
||||
searchResultSourcePath,
|
||||
searchVerifiedByApi: false,
|
||||
searchUiProviderKbScopeMissing: false,
|
||||
openReferenceControlsVisible: true,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
};
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
throw failLayer("browser_ui_verify", error.message || String(error), { screenshot: SCREENSHOT_PATH });
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signInRequest(context);
|
||||
const startedAt = Date.now();
|
||||
const fixture = createFolderFixture(startedAt);
|
||||
const name = `task788-weknora-kb-${startedAt}`;
|
||||
const browserResult = await verifyInBrowser(fixture, name);
|
||||
const providerKnowledgeBaseId = browserResult.providerKnowledgeBaseId;
|
||||
const searchResult = await searchUntilHit(context, fixture.markers[0], fixture.folderPath, providerKnowledgeBaseId);
|
||||
const afterStatus = await status(context);
|
||||
const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId);
|
||||
assert(statusBase, "status knowledgeBases 应包含新建 KB");
|
||||
assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task788-weknora-kb-folder-browser-flow-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
uiCreatesKb: true,
|
||||
uiAddsFolder: true,
|
||||
browserVerifiesKb: true,
|
||||
browserVerifiesSearch: true,
|
||||
browserVerifiesOpenReferenceControls: true,
|
||||
apiVerifiesSearch: true,
|
||||
folderPath: fixture.folderPath,
|
||||
absoluteFolderPath: fixture.absoluteFolderPath,
|
||||
markers: fixture.markers,
|
||||
knowledgeBase: {
|
||||
name,
|
||||
providerKnowledgeBaseId,
|
||||
baseId: statusBase.baseId,
|
||||
defaultToolEnabled: statusBase.defaultToolEnabled,
|
||||
sourceCount: statusBase.sourceCount,
|
||||
status: statusBase.status,
|
||||
},
|
||||
ingest: {
|
||||
configuredCount: fixture.sourcePaths.length,
|
||||
mappedCount: fixture.sourcePaths.length,
|
||||
sourcePaths: fixture.sourcePaths,
|
||||
},
|
||||
apiSearch: {
|
||||
provider: searchResult.payload.provider,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
|
||||
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
|
||||
providerChunkId: searchResult.reference.providerChunkId,
|
||||
},
|
||||
browser: browserResult,
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task788-weknora-kb-folder-browser-flow-smoke",
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task789-weknora-kb-page-ui-reference-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const REFERENCE_DOC = "design/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle) {
|
||||
assert(content.includes(needle), `reference doc 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
assert(fs.existsSync(path.join(ROOT, REFERENCE_DOC)), `${REFERENCE_DOC} 不存在`);
|
||||
|
||||
const doc = read(REFERENCE_DOC);
|
||||
for (const required of [
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBaseList.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBase.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/DocumentListView.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/KbUploadSourceDropdown.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/components/knowledge-processing-timeline.vue",
|
||||
"providerKnowledgeId",
|
||||
"不能直接接管的边界",
|
||||
"MNote 最小呈现建议",
|
||||
]) {
|
||||
assertContains(doc, required);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [REFERENCE_DOC],
|
||||
assertions: {
|
||||
referenceDocExists: true,
|
||||
weknoraKbListPathRecorded: true,
|
||||
weknoraKbDetailPathRecorded: true,
|
||||
documentListPathRecorded: true,
|
||||
uploadDropdownPathRecorded: true,
|
||||
processingTimelinePathRecorded: true,
|
||||
mnoteMappingRecorded: true,
|
||||
boundariesRecorded: true,
|
||||
minimumStructureRecorded: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task790-weknora-kb-page-experience-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
|
||||
const MAIN_CSS = "rust/crates/mnote-web/src/ssr/styles/components/main.css";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const runtime = read(SETTINGS_RUNTIME);
|
||||
const css = read(MAIN_CSS);
|
||||
|
||||
for (const required of [
|
||||
"data-mnote-weknora-page-replacement=\"true\"",
|
||||
"data-testid=\"mnote-weknora-kb-list-pane\"",
|
||||
"data-testid=\"mnote-weknora-kb-card-list\"",
|
||||
"data-testid=\"mnote-weknora-kb-breadcrumb\"",
|
||||
"data-testid=\"mnote-weknora-kb-detail-pane\"",
|
||||
"data-testid=\"mnote-weknora-tab-documents\"",
|
||||
"data-testid=\"mnote-weknora-tab-wiki\"",
|
||||
"data-testid=\"mnote-weknora-tab-graph\"",
|
||||
"data-testid=\"mnote-weknora-doc-source-sidebar\"",
|
||||
"data-testid=\"mnote-weknora-doc-source-tags\"",
|
||||
"data-testid=\"mnote-weknora-doc-tags\"",
|
||||
"data-testid=\"mnote-weknora-doc-filter-input\"",
|
||||
"data-testid=\"mnote-weknora-view-switch\"",
|
||||
"data-knowledge-rag-view=\"grid\"",
|
||||
"添加文件夹",
|
||||
"添加文档",
|
||||
"data-weknora-document-list-view=\"true\"",
|
||||
"data-knowledge-rag-processing-status",
|
||||
"data-knowledge-rag-action=\"open-source-reference\"",
|
||||
"data-knowledge-rag-open-reference-mode=\"source-registry-reference\"",
|
||||
"data-testid=\"mnote-weknora-wiki-panel\"",
|
||||
"data-testid=\"mnote-weknora-graph-panel\"",
|
||||
"MNote source registry / WeKnora KB chunk index",
|
||||
]) {
|
||||
assertContains(runtime, required, SETTINGS_RUNTIME);
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
".mnote-weknora-kb-breadcrumb",
|
||||
".mnote-weknora-doc-layout",
|
||||
".mnote-weknora-doc-sidebar",
|
||||
".mnote-weknora-doc-source-tags",
|
||||
".mnote-weknora-view-switch",
|
||||
".mnote-weknora-doc-table[data-weknora-document-view=\"grid\"]",
|
||||
".mnote-weknora-placeholder-panel",
|
||||
".mnote-weknora-source-aux",
|
||||
]) {
|
||||
assertContains(css, required, MAIN_CSS);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [SETTINGS_RUNTIME, MAIN_CSS],
|
||||
assertions: {
|
||||
kbListPageVisible: true,
|
||||
kbDetailBreadcrumbVisible: true,
|
||||
documentsWikiGraphTabsVisible: true,
|
||||
sourceAndTagSidebarVisible: true,
|
||||
searchAndFilterVisible: true,
|
||||
addDocumentAndFolderEntrypointsVisible: true,
|
||||
gridListSwitchVisible: true,
|
||||
processingStatusVisible: true,
|
||||
sourceRowsKeepCitationOpenReference: true,
|
||||
mnoteWeKnoraBoundaryPreserved: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const CONVERSION_RUNTIME = path.join(ROOT, "rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js");
|
||||
const SESSION_RUNTIME = fs.readFileSync(path.join(ROOT, "rust/crates/mnote-web/browser/document-session-runtime.js"), "utf8");
|
||||
const ADAPTER_RUNTIME = fs.readFileSync(path.join(ROOT, "rust/crates/mnote-web/browser/document-editor-adapter-runtime.js"), "utf8");
|
||||
|
||||
async function main() {
|
||||
global.window = { location: { origin: "http://127.0.0.1:3000" } };
|
||||
const runtime = await import(`file://${CONVERSION_RUNTIME}?t=${Date.now()}`);
|
||||
const href = "/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree";
|
||||
const block = runtime.tiptapNodeToEditorBlock({
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "local-block-1" },
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "完结项目",
|
||||
marks: [{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href,
|
||||
target: "_self",
|
||||
rel: "noopener noreferrer nofollow",
|
||||
class: "mnote-page-block-link",
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}, 0);
|
||||
|
||||
assert.equal(block.blockType, "page_reference");
|
||||
assert.equal(block.props.title, "完结项目");
|
||||
assert.equal(block.props.sourcePath, "liaibo的个人空间/项目/完结项目.md");
|
||||
|
||||
assert.match(SESSION_RUNTIME, /userReadOnlyMode/);
|
||||
assert.match(SESSION_RUNTIME, /setDocumentSessionUserReadOnlyMode/);
|
||||
assert.match(
|
||||
SESSION_RUNTIME,
|
||||
/userReadOnlyMode:\s*true/,
|
||||
"普通文档打开后必须默认只读;编辑只能由用户显式切换",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SESSION_RUNTIME,
|
||||
/userReadOnlyMode:\s*false/,
|
||||
"不能把用户编辑模式作为默认态,否则页面首屏不是只读浏览",
|
||||
);
|
||||
assert.match(ADAPTER_RUNTIME, /data-document-edit-mode-toggle/);
|
||||
assert.match(ADAPTER_RUNTIME, /开始编辑/);
|
||||
assert.match(ADAPTER_RUNTIME, /退出编辑/);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const blockMenuPath = path.join(
|
||||
ROOT,
|
||||
"rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_handle_menu_view.rs",
|
||||
);
|
||||
const routesPath = path.join(ROOT, "rust/crates/mnote-web/src/routes/mod.rs");
|
||||
const stylePath = path.join(
|
||||
ROOT,
|
||||
"rust/spikes/leptos-tiptap-spike/src/editor_runtime/style.rs",
|
||||
);
|
||||
|
||||
const blockMenu = fs.readFileSync(blockMenuPath, "utf8");
|
||||
const routes = fs.readFileSync(routesPath, "utf8");
|
||||
const style = fs.readFileSync(stylePath, "utf8");
|
||||
|
||||
assert.match(
|
||||
blockMenu,
|
||||
/mnote:toast/,
|
||||
"复制链接必须触发全局可见 toast,而不是只改 command_feedback",
|
||||
);
|
||||
assert.match(
|
||||
blockMenu,
|
||||
/已复制块链接/,
|
||||
"复制链接成功提示必须包含清晰的可见文案",
|
||||
);
|
||||
|
||||
["move", "history", "comment"].forEach((id) => {
|
||||
assert.match(
|
||||
blockMenu,
|
||||
new RegExp('data-testid="block-' + id + '-panel"'),
|
||||
id + " 入口必须打开可见面板,避免点击后仅 active 无反馈",
|
||||
);
|
||||
});
|
||||
|
||||
assert.doesNotMatch(
|
||||
blockMenu,
|
||||
/移动\/嵌入流程后续|块历史入口已记录|评论入口已打开|颜色子菜单后续/,
|
||||
"块菜单不能保留可点击但无真实 UI 的占位反馈",
|
||||
);
|
||||
assert.match(blockMenu, /data-testid="block-color-panel"/, "颜色入口必须打开真实颜色面板");
|
||||
assert.match(blockMenu, /apply_text_color\(editor, value\)/, "块菜单颜色面板必须能设置文字颜色");
|
||||
assert.match(blockMenu, /apply_highlight_color\(editor, Some\(value\)\)/, "块菜单颜色面板必须能设置背景色");
|
||||
assert.match(
|
||||
blockMenu,
|
||||
/data-testid="block-drag-menu-item-align-center"[\s\S]{0,240}selection_state\.get\(\)\.align_center/,
|
||||
"文字居中菜单项必须显示当前居中状态",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routes,
|
||||
/\/api\/documents\/buffer-state\/dirty:0/,
|
||||
"必须兼容旧浏览器误发的 /api/documents/buffer-state/dirty:0,避免控制台 404",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
style,
|
||||
/@media \(max-width: 768px\)[\s\S]*?\.block-handle-shell,[\s\S]*?display: none;/,
|
||||
"移动端不能隐藏块手柄;点击正文后必须保留可触达的块菜单入口",
|
||||
);
|
||||
assert.match(
|
||||
style,
|
||||
/@media \(max-width: 768px\)[\s\S]*?\.block-handle-trigger[\s\S]*?(?:width|height): (?:3[6-9]|4[0-9])px;/,
|
||||
"移动端块手柄触控目标至少应为 36px",
|
||||
);
|
||||
|
||||
console.log("task805 editor block menu static smoke passed");
|
||||
Reference in New Issue
Block a user