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:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
+67 -8
View File
@@ -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 Aggregatelocal-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 payloadopencode 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 payloadraw 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 bridgeSidebar 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 guardHermes 页面 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/` 做历史对照
原则:
+236
View File
@@ -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);
});
+3 -1
View File
@@ -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();
}
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
// Start mnote-web (mock Pi) on a free port and run S2S7 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✅ S2S7 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❌ S2S7 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);
});
-207
View File
@@ -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);
});
-144
View File
@@ -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);
});
-203
View File
@@ -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);
});
-333
View File
@@ -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);
});
-91
View File
@@ -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-webnpm 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 filtersource === '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");