feat(rag): harden post-LightRAG runtime
Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists. Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
This commit is contained in:
@@ -35,8 +35,8 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- 入口与认证:`task114-rust-web-gateway-entry-smoke.js`、`task159-auth-entry-smoke.js`、`task097-homepage-entry-smoke.js`
|
||||
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
|
||||
- leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
|
||||
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`
|
||||
- LightRAG 资料库:`task534-knowledge-rag-source-management-scope-smoke.js` 覆盖本地文件夹 source 加入资料库、registry 状态与范围管理;`task529-knowledge-rag-citation-resource-tab-smoke.js`、`task530-knowledge-rag-page-ai-final-answer-smoke.js` 覆盖资料库引用回到资源页与 Page AI 最终回答。旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档。
|
||||
- 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 脚本。
|
||||
- LightRAG 资料库:`task534-knowledge-rag-source-management-scope-smoke.js` 覆盖本地文件夹 source 加入资料库、registry 状态与范围管理;`task529-knowledge-rag-citation-resource-tab-smoke.js`、`task530-knowledge-rag-page-ai-final-answer-smoke.js` 覆盖资料库引用回到资源页与 Page AI 最终回答。旧 `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`
|
||||
|
||||
+209
-133
@@ -25,8 +25,8 @@ import { randomUUID } from 'node:crypto';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
|
||||
@@ -38,6 +38,41 @@ function debugLog(message) {
|
||||
|
||||
const toolContextStorage = new AsyncLocalStorage();
|
||||
|
||||
const MNOTE_TOOL_NAMES = [
|
||||
'mnote.skill.read',
|
||||
'mnote.context.snapshot',
|
||||
'mnote.context.resolve_target',
|
||||
'mnote.context.read_current_page',
|
||||
'mnote.knowledge_rag.status',
|
||||
'mnote.knowledge_rag.query',
|
||||
'mnote.knowledge_rag.open_reference',
|
||||
];
|
||||
|
||||
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||
mnote_skill_read: 'mnote.skill.read',
|
||||
mnote_context_snapshot: 'mnote.context.snapshot',
|
||||
mnote_context_resolve_target: 'mnote.context.resolve_target',
|
||||
mnote_context_read_current_page: 'mnote.context.read_current_page',
|
||||
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
|
||||
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
|
||||
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
|
||||
};
|
||||
|
||||
function readRustManifestToolNames() {
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const manifestPath = join(scriptDir, '..', 'rust', 'crates', 'mnote-web', 'src', 'hermes_tools', 'manifest.rs');
|
||||
const source = readFileSync(manifestPath, 'utf8');
|
||||
return Array.from(source.matchAll(/"name":\s*"([^"]+)"/g)).map((match) => match[1]);
|
||||
}
|
||||
|
||||
function assertSameSortedSet(actual, expected, label) {
|
||||
const actualSorted = [...actual].sort();
|
||||
const expectedSorted = [...expected].sort();
|
||||
if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) {
|
||||
throw new Error(`${label} mismatch: actual=${JSON.stringify(actualSorted)} expected=${JSON.stringify(expectedSorted)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isWriteMnoteTool(toolName) {
|
||||
return ![
|
||||
'mnote.skill.read',
|
||||
@@ -278,6 +313,23 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
|
||||
if (toolResultStatusFromContent(failedEvidenceToolResult) !== 'failed') {
|
||||
throw new Error('selftest expected ok:false evidence result to be failed');
|
||||
}
|
||||
const rustKnowledgeTools = readRustManifestToolNames()
|
||||
.filter((toolName) => toolName.startsWith('mnote.knowledge_rag.'));
|
||||
const wrapperKnowledgeTools = MNOTE_TOOL_NAMES
|
||||
.filter((toolName) => toolName.startsWith('mnote.knowledge_rag.'));
|
||||
assertSameSortedSet(
|
||||
wrapperKnowledgeTools,
|
||||
rustKnowledgeTools,
|
||||
'selftest expected Reasonix MNOTE_TOOL_NAMES to match Rust knowledge RAG manifest tools',
|
||||
);
|
||||
const wrapperMappedKnowledgeTools = Object.entries(REASONIX_TOOL_TO_MNOTE_TOOL)
|
||||
.filter(([reasonixName]) => reasonixName.startsWith('mnote_knowledge_rag_'))
|
||||
.map(([, toolName]) => toolName);
|
||||
assertSameSortedSet(
|
||||
wrapperMappedKnowledgeTools,
|
||||
rustKnowledgeTools,
|
||||
'selftest expected Reasonix wrapper tool mapping to match Rust knowledge RAG manifest tools',
|
||||
);
|
||||
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -498,26 +550,6 @@ function emitUsage(sessionId, used, size) {
|
||||
// Calls mnote-web's Rust tool endpoints via HTTP.
|
||||
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
|
||||
|
||||
const MNOTE_TOOL_NAMES = [
|
||||
'mnote.skill.read',
|
||||
'mnote.context.snapshot',
|
||||
'mnote.context.resolve_target',
|
||||
'mnote.context.read_current_page',
|
||||
'mnote.knowledge_rag.status',
|
||||
'mnote.knowledge_rag.query',
|
||||
'mnote.knowledge_rag.open_reference',
|
||||
];
|
||||
|
||||
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||
mnote_skill_read: 'mnote.skill.read',
|
||||
mnote_context_snapshot: 'mnote.context.snapshot',
|
||||
mnote_context_resolve_target: 'mnote.context.resolve_target',
|
||||
mnote_context_read_current_page: 'mnote.context.read_current_page',
|
||||
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
|
||||
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
|
||||
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
|
||||
};
|
||||
|
||||
async function callMnoteTool(toolName, args) {
|
||||
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
||||
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
|
||||
@@ -542,121 +574,165 @@ async function callMnoteTool(toolName, args) {
|
||||
const tools = new ToolRegistry();
|
||||
const chatOnlyTools = new ToolRegistry();
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_skill_read',
|
||||
description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
skillId: { type: 'string', description: 'MNote skill ID' },
|
||||
agentId: { type: 'string', description: '当前 agent ID' },
|
||||
},
|
||||
required: ['skillId'],
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_skill_read, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
function reasonixToolNameForMnoteTool(toolName) {
|
||||
return String(toolName || '').trim().replace(/\./g, '_');
|
||||
}
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_context_snapshot',
|
||||
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
contextRefs: { type: 'array', items: {} },
|
||||
},
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_snapshot, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_context_resolve_target',
|
||||
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_resolve_target, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_context_read_current_page',
|
||||
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' },
|
||||
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
|
||||
},
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_read_current_page, args),
|
||||
parallelSafe: false,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_knowledge_rag_status',
|
||||
description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
},
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_status, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_knowledge_rag_query',
|
||||
description: '向 LightRAG 资料库提问,返回 answer、provider 原始结果和经 MNote registry 映射后的 references。回答必须引用返回来源。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '资料库问题' },
|
||||
question: { type: 'string', description: '资料库问题,等价于 query' },
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
mode: { type: 'string', enum: ['mix', 'local', 'global', 'naive', 'bypass'], description: 'LightRAG query mode; use mix by default for knowledge-library questions' },
|
||||
topK: { type: 'integer', description: 'LightRAG top_k' },
|
||||
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
|
||||
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
|
||||
sourcePaths: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。',
|
||||
function fallbackMnoteToolSpecs() {
|
||||
return [
|
||||
{
|
||||
mnoteToolName: 'mnote.skill.read',
|
||||
name: 'mnote_skill_read',
|
||||
description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
skillId: { type: 'string', description: 'MNote skill ID' },
|
||||
agentId: { type: 'string', description: '当前 agent ID' },
|
||||
},
|
||||
required: ['skillId'],
|
||||
},
|
||||
parallelSafe: true,
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_query, args),
|
||||
parallelSafe: false,
|
||||
});
|
||||
{
|
||||
mnoteToolName: 'mnote.context.snapshot',
|
||||
name: 'mnote_context_snapshot',
|
||||
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
|
||||
parameters: { type: 'object', properties: { contextRefs: { type: 'array', items: {} } } },
|
||||
parallelSafe: true,
|
||||
},
|
||||
{
|
||||
mnoteToolName: 'mnote.context.resolve_target',
|
||||
name: 'mnote_context_resolve_target',
|
||||
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
parallelSafe: true,
|
||||
},
|
||||
{
|
||||
mnoteToolName: 'mnote.context.read_current_page',
|
||||
name: 'mnote_context_read_current_page',
|
||||
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' },
|
||||
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
|
||||
},
|
||||
},
|
||||
parallelSafe: false,
|
||||
},
|
||||
{
|
||||
mnoteToolName: 'mnote.knowledge_rag.status',
|
||||
name: 'mnote_knowledge_rag_status',
|
||||
description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
},
|
||||
},
|
||||
parallelSafe: true,
|
||||
},
|
||||
{
|
||||
mnoteToolName: 'mnote.knowledge_rag.query',
|
||||
name: 'mnote_knowledge_rag_query',
|
||||
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references。回答必须引用返回来源,不要引用 raw chunks。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '资料库问题' },
|
||||
question: { type: 'string', description: '资料库问题,等价于 query' },
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
mode: { type: 'string', enum: ['mix', 'local', 'global', 'naive', 'bypass'], description: 'LightRAG query mode; use mix by default for knowledge-library questions' },
|
||||
topK: { type: 'integer', description: 'LightRAG top_k' },
|
||||
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
|
||||
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
|
||||
sourcePaths: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后过滤 references,raw 仍可能是全局结果。',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
parallelSafe: false,
|
||||
},
|
||||
{
|
||||
mnoteToolName: 'mnote.knowledge_rag.open_reference',
|
||||
name: 'mnote_knowledge_rag_open_reference',
|
||||
description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reference: { type: 'object', description: 'mnote_knowledge_rag_query 返回的 reference' },
|
||||
referenceId: { type: 'string' },
|
||||
filePath: { type: 'string', description: 'LightRAG reference file_path' },
|
||||
chunkId: { type: 'string' },
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
},
|
||||
required: ['filePath'],
|
||||
},
|
||||
parallelSafe: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_knowledge_rag_open_reference',
|
||||
description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reference: { type: 'object', description: 'mnote_knowledge_rag_query 返回的 reference' },
|
||||
referenceId: { type: 'string' },
|
||||
filePath: { type: 'string', description: 'LightRAG reference file_path' },
|
||||
chunkId: { type: 'string' },
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
async function loadMnoteManifestToolSpecs() {
|
||||
const response = await fetch(`${MNOTE_WEB_URL}/api/hermes/tools/mnote/manifest`, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'x-mnote-actor-id': process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
|
||||
'x-mnote-actor-type': 'agent',
|
||||
},
|
||||
required: ['filePath'],
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_open_reference, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
});
|
||||
if (!response.ok) throw new Error(`manifest_http_${response.status}`);
|
||||
const payload = await response.json();
|
||||
const manifest = payload?.manifest && typeof payload.manifest === 'object' ? payload.manifest : payload;
|
||||
const toolsByName = new Map((Array.isArray(manifest?.tools) ? manifest.tools : [])
|
||||
.filter((tool) => tool && typeof tool.name === 'string')
|
||||
.map((tool) => [tool.name, tool]));
|
||||
const specs = MNOTE_TOOL_NAMES.map((mnoteToolName) => {
|
||||
const tool = toolsByName.get(mnoteToolName);
|
||||
if (!tool) return null;
|
||||
return {
|
||||
mnoteToolName,
|
||||
name: reasonixToolNameForMnoteTool(mnoteToolName),
|
||||
description: String(tool.description || mnoteToolName),
|
||||
parameters: tool.inputSchema || { type: 'object', properties: {} },
|
||||
parallelSafe: mnoteToolName !== 'mnote.knowledge_rag.query' && mnoteToolName !== 'mnote.context.read_current_page',
|
||||
};
|
||||
}).filter(Boolean);
|
||||
return specs.length === MNOTE_TOOL_NAMES.length ? specs : [];
|
||||
}
|
||||
|
||||
function registerMnoteToolSpecs(registry, specs) {
|
||||
for (const spec of specs) {
|
||||
REASONIX_TOOL_TO_MNOTE_TOOL[spec.name] = spec.mnoteToolName;
|
||||
registry.register({
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
parameters: spec.parameters,
|
||||
readOnly: !isWriteMnoteTool(spec.mnoteToolName),
|
||||
fn: async (args) => callMnoteTool(spec.mnoteToolName, args),
|
||||
parallelSafe: Boolean(spec.parallelSafe),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mnoteToolSpecsSource = 'static_fallback';
|
||||
let mnoteToolSpecs = [];
|
||||
try {
|
||||
mnoteToolSpecs = await loadMnoteManifestToolSpecs();
|
||||
if (mnoteToolSpecs.length) mnoteToolSpecsSource = 'runtime_manifest';
|
||||
} catch (error) {
|
||||
debugLog(`[reasonix-acp-mnote] manifest dynamic tool registration unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!mnoteToolSpecs.length) mnoteToolSpecs = fallbackMnoteToolSpecs();
|
||||
registerMnoteToolSpecs(tools, mnoteToolSpecs);
|
||||
debugLog(`[reasonix-acp-mnote] registered ${mnoteToolSpecs.length} mnote tools from ${mnoteToolSpecsSource}`);
|
||||
|
||||
// ── Session Store ────────────────────────────────────
|
||||
|
||||
@@ -699,7 +775,7 @@ onRequest('session/new', async (params) => {
|
||||
'<available-skills>',
|
||||
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
|
||||
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
|
||||
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; if locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
|
||||
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
|
||||
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
|
||||
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
|
||||
'</available-skills>',
|
||||
|
||||
@@ -92,6 +92,58 @@ async function browserRefreshLocalIndex(page, root) {
|
||||
}, { rootUri: fileUrl(root) });
|
||||
}
|
||||
|
||||
async function browserUpdateLocalIndexSettings(page, root) {
|
||||
return page.evaluate(async ({ rootUri }) => {
|
||||
const response = await fetch("/api/search/local-index/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "local-ws:user_real:task452",
|
||||
rootUri,
|
||||
includePaths: ["."],
|
||||
scheduleMode: "manual",
|
||||
scheduleTime: "02:00",
|
||||
runOnChange: false,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return {
|
||||
status: response.status,
|
||||
payload,
|
||||
};
|
||||
}, { rootUri: fileUrl(root) });
|
||||
}
|
||||
|
||||
async function browserFetchLocalIndexBacklinksAndTags(page, root, documentId) {
|
||||
return page.evaluate(async ({ rootUri, documentIdValue }) => {
|
||||
const backlinksParams = new URLSearchParams({
|
||||
workspaceId: "local-ws:user_real:task452",
|
||||
rootUri,
|
||||
documentId: documentIdValue,
|
||||
});
|
||||
const tagsParams = new URLSearchParams({
|
||||
workspaceId: "local-ws:user_real:task452",
|
||||
rootUri,
|
||||
});
|
||||
const backlinksResponse = await fetch(`/api/search/local-index/backlinks?${backlinksParams.toString()}`, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const tagsResponse = await fetch(`/api/search/local-index/tags?${tagsParams.toString()}`, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
return {
|
||||
backlinks: {
|
||||
status: backlinksResponse.status,
|
||||
payload: await backlinksResponse.json().catch(() => null),
|
||||
},
|
||||
tags: {
|
||||
status: tagsResponse.status,
|
||||
payload: await tagsResponse.json().catch(() => null),
|
||||
},
|
||||
};
|
||||
}, { rootUri: fileUrl(root), documentIdValue: documentId });
|
||||
}
|
||||
|
||||
async function capturePanelDiagnostics(page) {
|
||||
try {
|
||||
return await page.evaluate(function() {
|
||||
@@ -99,12 +151,16 @@ async function capturePanelDiagnostics(page) {
|
||||
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
||||
var st = document.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
||||
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||
var ranges = document.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
|
||||
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
|
||||
return {
|
||||
backlinksHtml: bl ? bl.innerHTML : '(missing)',
|
||||
tagsHtml: tg ? tg.innerHTML : '(missing)',
|
||||
statusText: st ? st.textContent : '(missing)',
|
||||
popoverExists: Boolean(popover),
|
||||
popoverHidden: popover ? popover.hidden : null,
|
||||
rangeInputs: ranges ? ranges.querySelectorAll('[data-local-index-range-input]').length : 0,
|
||||
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
|
||||
locationHref: window.location.href,
|
||||
};
|
||||
});
|
||||
@@ -157,6 +213,9 @@ async function run() {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const settingsBeforeSearch = await browserUpdateLocalIndexSettings(page, root);
|
||||
assert.equal(settingsBeforeSearch.status, 200, `更新本地索引设置应成功: ${JSON.stringify(settingsBeforeSearch)}`);
|
||||
debug.settingsBeforeSearch = settingsBeforeSearch.payload;
|
||||
const refreshedBeforeSearch = await browserRefreshLocalIndex(page, root);
|
||||
assert.equal(refreshedBeforeSearch.status, 200, `刷新本地索引应成功: ${JSON.stringify(refreshedBeforeSearch)}`);
|
||||
debug.refreshedBeforeSearch = refreshedBeforeSearch.payload;
|
||||
@@ -173,23 +232,19 @@ async function run() {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-local-index-settings-popover"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
debug.navigationIndexSettings = await page.evaluate(function() {
|
||||
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||
var ranges = document.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
|
||||
var indexToggle = document.querySelector('[data-testid="mnote-local-index-settings-toggle"]');
|
||||
var ragToggle = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]');
|
||||
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
|
||||
return {
|
||||
path: window.location.pathname,
|
||||
visible: Boolean(popover && !popover.hidden),
|
||||
rangeInputs: ranges ? ranges.querySelectorAll('[data-local-index-range-input]').length : 0,
|
||||
hasLocalIndexToggle: Boolean(indexToggle),
|
||||
hasKnowledgeRagToggle: Boolean(ragToggle),
|
||||
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
|
||||
};
|
||||
});
|
||||
assert.equal(debug.navigationIndexSettings.visible, true, `导航页应能打开索引设置: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||
assert.equal(debug.navigationIndexSettings.hasLocalIndexToggle, false, `导航页不应恢复旧本地索引设置入口: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||
assert.equal(debug.navigationIndexSettings.hasKnowledgeRagToggle, true, `导航页应保留资料库问答入口: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||
assert.equal(debug.navigationIndexSettings.hasPageSettingsIndexTab, false, `索引设置不应留在页面设置页签: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||
|
||||
await page.goto(documentUrl(root, firstRelativePath), {
|
||||
@@ -201,49 +256,46 @@ async function run() {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// 拦截 API 请求与页面错误做诊断
|
||||
// 拦截页面错误做诊断;backlink/tag 当前不再由索引面板渲染,下面走 API 直验。
|
||||
var diagApiResponses = {};
|
||||
page.on('response', function onDiag(resp) {
|
||||
var url = resp.url();
|
||||
if (url.includes('/api/search/local-index/backlinks') || url.includes('/api/search/local-index/tags')) {
|
||||
resp.json().then(function(body) { diagApiResponses[url] = body; }).catch(function() {});
|
||||
}
|
||||
});
|
||||
page.on('pageerror', function onPageError(err) { diagApiResponses._pageError = String(err); });
|
||||
page.on('console', function onConsole(msg) {
|
||||
if (msg.type() === 'error') { diagApiResponses._consoleErrors = (diagApiResponses._consoleErrors || []).concat([msg.text()]); }
|
||||
});
|
||||
|
||||
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
// 等待本地索引面板渲染;超时时捕获 DOM 诊断再抛
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
var bl = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
||||
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
||||
return Boolean(
|
||||
bl && bl.textContent && bl.textContent.includes("README.md")
|
||||
&& bl.textContent.includes("[Target](docs/search-target.md)")
|
||||
&& tg && tg.textContent && tg.textContent.includes("#alpha"),
|
||||
);
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
} catch (waitError) {
|
||||
debug.diagApiResponses = diagApiResponses;
|
||||
debug.failedPanelState = await capturePanelDiagnostics(page);
|
||||
throw waitError;
|
||||
}
|
||||
|
||||
debug.localIndexPanel = await page.evaluate(function() {
|
||||
var bl = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
||||
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
||||
var st = document.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
||||
var indexToggle = document.querySelector('[data-testid="mnote-local-index-settings-toggle"]');
|
||||
var ragToggle = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]');
|
||||
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
|
||||
return {
|
||||
backlinks: bl ? bl.innerHTML : '(missing)',
|
||||
tags: tg ? tg.innerHTML : '(missing)',
|
||||
status: st ? st.textContent : '(missing)',
|
||||
hasLocalIndexToggle: Boolean(indexToggle),
|
||||
hasKnowledgeRagToggle: Boolean(ragToggle),
|
||||
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
|
||||
hasLegacyBacklinksDom: Boolean(document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]')),
|
||||
hasLegacyTagsDom: Boolean(document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]')),
|
||||
};
|
||||
});
|
||||
debug.diagApiResponses = diagApiResponses;
|
||||
assert.equal(debug.localIndexPanel.hasLocalIndexToggle, false, `文档页不应恢复旧本地索引设置入口: ${JSON.stringify(debug.localIndexPanel)}`);
|
||||
assert.equal(debug.localIndexPanel.hasKnowledgeRagToggle, true, `文档页应保留资料库问答入口: ${JSON.stringify(debug.localIndexPanel)}`);
|
||||
assert.equal(debug.localIndexPanel.hasPageSettingsIndexTab, false, `文档页索引设置不应回落到页面设置页签: ${JSON.stringify(debug.localIndexPanel)}`);
|
||||
assert.equal(debug.localIndexPanel.hasLegacyBacklinksDom, false, `独立索引面板不应恢复旧 backlink DOM: ${JSON.stringify(debug.localIndexPanel)}`);
|
||||
assert.equal(debug.localIndexPanel.hasLegacyTagsDom, false, `独立索引面板不应恢复旧 tags DOM: ${JSON.stringify(debug.localIndexPanel)}`);
|
||||
|
||||
const backlinksAndTags = await browserFetchLocalIndexBacklinksAndTags(page, root, localMdDocumentId(firstRelativePath));
|
||||
assert.equal(backlinksAndTags.backlinks.status, 200, `反链 API 应继续可用: ${JSON.stringify(backlinksAndTags)}`);
|
||||
assert.equal(backlinksAndTags.tags.status, 200, `标签 API 应继续可用: ${JSON.stringify(backlinksAndTags)}`);
|
||||
debug.backlinksAndTags = backlinksAndTags;
|
||||
const backlinks = backlinksAndTags.backlinks.payload?.result?.backlinks || [];
|
||||
assert(
|
||||
backlinks.some((item) => item.documentId === localMdDocumentId("README.md")),
|
||||
`本地索引反链应能找到 README.md: ${JSON.stringify(backlinksAndTags.backlinks.payload)}`,
|
||||
);
|
||||
const tags = backlinksAndTags.tags.payload?.result?.tags || [];
|
||||
assert(
|
||||
tags.some((item) => item.tag === "alpha"),
|
||||
`本地索引标签 API 应能返回 alpha: ${JSON.stringify(backlinksAndTags.tags.payload)}`,
|
||||
);
|
||||
|
||||
fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath));
|
||||
const refreshedAfterRename = await browserRefreshLocalIndex(page, root);
|
||||
|
||||
@@ -9,8 +9,8 @@ const { chromium } = require("playwright");
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const QUERY = process.env.MNOTE_KNOWLEDGE_RAG_QUERY || "scan image start";
|
||||
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE || "knowledge-rag-fixtures-7-50/scan-image-start.pdf";
|
||||
const QUERY = process.env.MNOTE_KNOWLEDGE_RAG_QUERY || "image copy 6.png";
|
||||
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE || "新页面233155/image copy 6.png";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task529-knowledge-rag-citation-resource-tab-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "citation-resource-tab.png");
|
||||
@@ -48,17 +48,18 @@ async function main() {
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [EXPECTED_RESOURCE],
|
||||
},
|
||||
});
|
||||
assert(queryResponse.ok(), `knowledge-rag query 失败: ${queryResponse.status()} ${await queryResponse.text()}`);
|
||||
const queryPayload = await queryResponse.json();
|
||||
const reference = (queryPayload.references || []).find((item) => item.sourceRootRelativePath === EXPECTED_RESOURCE && item.citationUrl);
|
||||
assert(reference, `缺少目标引用 ${EXPECTED_RESOURCE}: ${JSON.stringify(queryPayload.references || [], null, 2).slice(0, 3000)}`);
|
||||
assert.equal(reference.locatorDegraded, false, `目标引用不应降级: ${JSON.stringify(reference, null, 2)}`);
|
||||
assert(reference.locator?.page, "目标引用缺少 page");
|
||||
assert(reference.locator?.bbox, "目标引用缺少 bbox");
|
||||
assert.equal(reference.locatorDegraded, true, `当前 image wrapper 引用应明确降级: ${JSON.stringify(reference, null, 2)}`);
|
||||
assert(!reference.locator?.page, `降级引用不应伪造 page: ${JSON.stringify(reference, null, 2)}`);
|
||||
assert(!reference.locator?.bbox, `降级引用不应伪造 bbox: ${JSON.stringify(reference, null, 2)}`);
|
||||
assert(String(reference.citationUrl || "").includes("resourceTab="), "query citationUrl 缺少 resourceTab");
|
||||
assert(String(reference.citationMarkdown || "").includes("p."), "query citationMarkdown 缺少页码");
|
||||
assert(String(reference.citationMarkdown || "").includes("来源定位降级"), "query citationMarkdown 缺少降级口径");
|
||||
|
||||
const openResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/open-reference`, {
|
||||
data: {
|
||||
@@ -72,9 +73,9 @@ async function main() {
|
||||
assert(openResponse.ok(), `knowledge-rag open_reference 失败: ${openResponse.status()} ${await openResponse.text()}`);
|
||||
const openPayload = await openResponse.json();
|
||||
assert.equal(openPayload.ok, true, `open_reference ok=false: ${JSON.stringify(openPayload, null, 2)}`);
|
||||
assert.equal(openPayload.reference?.locatorDegraded, false, `open_reference locator 降级: ${JSON.stringify(openPayload.reference, null, 2)}`);
|
||||
assert.equal(openPayload.reference?.locatorDegraded, true, `open_reference 应保持 locator 降级: ${JSON.stringify(openPayload.reference, null, 2)}`);
|
||||
assert(String(openPayload.reference?.citationUrl || "").includes("resourceTab="), "open_reference citationUrl 缺少 resourceTab");
|
||||
assert(String(openPayload.reference?.citationMarkdown || "").includes("p."), "open_reference citationMarkdown 缺少页码");
|
||||
assert(String(openPayload.reference?.citationMarkdown || "").includes("来源定位降级"), "open_reference citationMarkdown 缺少降级口径");
|
||||
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
@@ -95,6 +96,7 @@ async function main() {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`);
|
||||
const inlineViewer = document.querySelector("[data-mnote-inline-pdf-viewer]");
|
||||
const evidencePage = document.querySelector("[data-mnote-evidence-page='true']");
|
||||
const image = panel ? panel.querySelector("img, [data-mnote-image-viewer], [data-mnote-resource-image]") : null;
|
||||
return {
|
||||
url: location.href,
|
||||
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
|
||||
@@ -103,6 +105,7 @@ async function main() {
|
||||
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
|
||||
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
|
||||
inlinePdfVisible: !!inlineViewer,
|
||||
imageVisible: !!image,
|
||||
evidencePageRendered: !!evidencePage,
|
||||
evidencePageNumber: evidencePage ? evidencePage.getAttribute("data-page-number") : "",
|
||||
};
|
||||
@@ -110,9 +113,7 @@ async function main() {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
assert.equal(state.panelVisible, true, `citationUrl 未打开资源标签页: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.panelResourcePath, EXPECTED_RESOURCE, `资源标签页路径不匹配: ${JSON.stringify(state, null, 2)}`);
|
||||
assert(String(state.panelLocator || "").includes('"page"'), `资源标签页缺少 evidence locator: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.inlinePdfVisible, true, `资源标签页 PDF 内容未渲染: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.evidencePageRendered, true, `资源标签页未渲染 evidence page 高亮: ${JSON.stringify(state, null, 2)}`);
|
||||
assert(!String(state.panelLocator || "").includes('"page"'), `降级资源标签页不应携带伪造 page locator: ${JSON.stringify(state, null, 2)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
@@ -123,8 +124,7 @@ async function main() {
|
||||
expectedResource: EXPECTED_RESOURCE,
|
||||
citationUrl: reference.citationUrl,
|
||||
citationMarkdown: reference.citationMarkdown,
|
||||
page: reference.locator.page,
|
||||
bbox: reference.locator.bbox,
|
||||
locatorDegraded: reference.locatorDegraded,
|
||||
openReferenceCitationUrl: openPayload.reference.citationUrl,
|
||||
state,
|
||||
pageErrors,
|
||||
|
||||
@@ -177,8 +177,8 @@ async function main() {
|
||||
.count();
|
||||
const prompt = [
|
||||
"请调用 mnote_knowledge_rag_query 检索资料库。",
|
||||
"问题:scan image start 这份扫描 PDF 里出现了什么关键短语?",
|
||||
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接,不要描述检索过程,不要输出 raw JSON,不要编造页码。",
|
||||
"问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 [\"新页面233155/image copy 6.png\"]。",
|
||||
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接;如果 locatorDegraded=true,必须说明来源定位降级,不要描述检索过程,不要输出 raw JSON,不要编造页码或 bbox。",
|
||||
].join("\n");
|
||||
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 });
|
||||
@@ -194,7 +194,7 @@ async function main() {
|
||||
);
|
||||
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
|
||||
const text = node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
|
||||
return text.includes("first image in PDF") && text.includes("scan-image-start.pdf") && text.includes("p.1");
|
||||
return text.includes("来源定位降级") && text.includes("image copy 6.png");
|
||||
},
|
||||
assistantCount,
|
||||
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
|
||||
@@ -202,11 +202,12 @@ async function main() {
|
||||
|
||||
const assistantText = await newestAssistantText(page, assistantCount);
|
||||
const assistantLinks = await newestAssistantLinks(page, assistantCount);
|
||||
assert(assistantText.includes("first image in PDF"), `可见回答缺少关键短语: ${assistantText}`);
|
||||
assert(assistantText.includes("scan-image-start.pdf"), `可见回答缺少来源文件名: ${assistantText}`);
|
||||
assert(assistantText.includes("p.1"), `可见回答缺少页码 citation 文本: ${assistantText}`);
|
||||
assert(assistantText.includes("image copy 6.png"), `可见回答缺少当前资料来源文件名: ${assistantText}`);
|
||||
assert(assistantText.includes("来源定位降级"), `可见回答缺少 degraded citation 口径: ${assistantText}`);
|
||||
assert(!/\bp\.\d+\b/i.test(assistantText), `degraded citation 不应编造页码: ${assistantText}`);
|
||||
assert(!/bbox/i.test(assistantText), `degraded citation 不应编造 bbox: ${assistantText}`);
|
||||
assert(
|
||||
assistantLinks.some((link) => link.text.includes("scan-image-start.pdf") && link.href.includes("resourceTab=")),
|
||||
assistantLinks.some((link) => link.text.includes("来源定位降级") && link.href.includes("resourceTab=")),
|
||||
`可见回答缺少可点击 resourceTab citation: ${JSON.stringify(assistantLinks)}`,
|
||||
);
|
||||
assert(!assistantText.includes("citationMarkdown"), `可见回答泄漏工具字段名: ${assistantText}`);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/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 ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
|
||||
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
|
||||
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task531-lightrag-dashboard-ui-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
|
||||
async function fetchDashboardStatus(context) {
|
||||
const response = await context.request.get(`${BASE_URL}/api/knowledge-rag/status`, {
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": "local-ws:mnote-e2e:my-space",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `/api/knowledge-rag/status 失败: ${response.status()} ${text.slice(0, 500)}`);
|
||||
const payload = JSON.parse(text);
|
||||
assert(payload.dashboardUrl, `status 缺少 dashboardUrl: ${text.slice(0, 500)}`);
|
||||
assert(payload.health?.healthy !== false, `LightRAG health 非 healthy: ${text.slice(0, 500)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function bodyText(page) {
|
||||
return await page.evaluate(() => document.body.innerText || "");
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
const screenshotPath = path.join(OUTPUT_DIR, name);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
return screenshotPath;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_DASHBOARD_SMOKE_HEADED !== "1",
|
||||
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
|
||||
try {
|
||||
const statusPayload = await fetchDashboardStatus(context);
|
||||
const dashboardUrl = statusPayload.dashboardUrl;
|
||||
const documentSummaries = Array.isArray(statusPayload.documents?.documents)
|
||||
? statusPayload.documents.documents
|
||||
: [];
|
||||
const rawStatusGroups = statusPayload.documents?.rawStatusGroups || {};
|
||||
const processedCount = Number(rawStatusGroups.processed || 0);
|
||||
const webuiUrl = dashboardUrl.endsWith("/webui/") ? dashboardUrl : `${dashboardUrl.replace(/\/+$/, "")}/webui/`;
|
||||
await page.goto(webuiUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForLoadState("networkidle", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.waitForFunction(
|
||||
() => (document.body.innerText || "").includes("Document Management"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const documentsText = await bodyText(page);
|
||||
assert(documentsText.includes("Uploaded Documents"), "Documents 页缺少 Uploaded Documents");
|
||||
if (processedCount > 0) {
|
||||
assert(
|
||||
documentsText.includes(`Completed (${processedCount})`),
|
||||
`Documents 页未显示当前 completed 计数 ${processedCount}: ${documentsText.slice(0, 1000)}`,
|
||||
);
|
||||
}
|
||||
const visibleDocument = documentSummaries.find((doc) => {
|
||||
const id = String(doc.id || "");
|
||||
const filePath = String(doc.filePath || "");
|
||||
return (id && documentsText.includes(id)) || (filePath && documentsText.includes(filePath));
|
||||
});
|
||||
assert(
|
||||
visibleDocument,
|
||||
`Documents 页没有显示 status API 返回的任一当前文档: ${JSON.stringify(documentSummaries.slice(0, 5), null, 2)}\n${documentsText.slice(0, 1000)}`,
|
||||
);
|
||||
const documentsScreenshot = await screenshot(page, "documents.png");
|
||||
|
||||
await page.getByRole("tab", { name: /Knowledge Graph/i }).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.body.innerText || "";
|
||||
return text.includes("Connected") && text.includes("D:");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const graphText = await bodyText(page);
|
||||
assert(graphText.includes("Connected"), `Graph 页缺少连接状态: ${graphText.slice(0, 1000)}`);
|
||||
const graphScreenshot = await screenshot(page, "graph.png");
|
||||
|
||||
await page.getByRole("tab", { name: /Retrieval/i }).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => (document.body.innerText || "").includes("Start a retrieval by typing your query below"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const retrievalText = await bodyText(page);
|
||||
assert(retrievalText.includes("Query Mode"), "Retrieval 页缺少 Query Mode 参数");
|
||||
assert(retrievalText.includes("KG Top K"), "Retrieval 页缺少 KG Top K 参数");
|
||||
assert(retrievalText.includes("Connected"), "Retrieval 页缺少连接状态");
|
||||
const retrievalScreenshot = await screenshot(page, "retrieval.png");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
dashboardUrl,
|
||||
webuiUrl: page.url(),
|
||||
documents: {
|
||||
processedCount,
|
||||
completedVisible: processedCount > 0 ? documentsText.includes(`Completed (${processedCount})`) : null,
|
||||
visibleDocumentId: visibleDocument.id || null,
|
||||
visibleDocumentFilePath: visibleDocument.filePath || null,
|
||||
screenshot: documentsScreenshot,
|
||||
},
|
||||
graph: {
|
||||
connectedVisible: graphText.includes("Connected"),
|
||||
screenshot: graphScreenshot,
|
||||
},
|
||||
retrieval: {
|
||||
queryModeVisible: retrievalText.includes("Query Mode"),
|
||||
connectedVisible: retrievalText.includes("Connected"),
|
||||
screenshot: retrievalScreenshot,
|
||||
},
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "failure.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -125,6 +125,7 @@ async function openKnowledgePanel(page) {
|
||||
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 });
|
||||
}
|
||||
|
||||
async function waitForSourceRow(page, sourcePath) {
|
||||
@@ -155,6 +156,8 @@ async function main() {
|
||||
|
||||
const scoped = await query(context, marker, [sourceA]);
|
||||
const scopedReferences = Array.isArray(scoped.references) ? scoped.references : [];
|
||||
assert.equal(scoped.sourceScopeMode, "post_filter_mapped_references", `sourcePaths scope mode 应明确为 post-filter: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(scoped.rawScopeFiltered, false, `rawScopeFiltered 应明确提示 provider raw 未被 sourcePaths 预过滤: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
|
||||
assert(scopedReferences.length > 0, `sourcePaths scope 应至少返回 alpha: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
|
||||
assert(
|
||||
scopedReferences.every((reference) => reference.sourceRootRelativePath === sourceA),
|
||||
@@ -170,7 +173,8 @@ async function main() {
|
||||
await page.waitForFunction(
|
||||
(expectedPath) => {
|
||||
const row = document.querySelector(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${expectedPath}"]`);
|
||||
return row && (row.textContent || "").includes("已删除");
|
||||
const text = row ? row.textContent || "" : "";
|
||||
return text.includes("已删除") || text.includes("删除已提交") || text.includes("LightRAG 已移除");
|
||||
},
|
||||
sourceA,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
@@ -191,6 +195,8 @@ async function main() {
|
||||
sourceA,
|
||||
sourceB,
|
||||
sourceScope: scoped.sourceScope,
|
||||
sourceScopeMode: scoped.sourceScopeMode,
|
||||
rawScopeFiltered: scoped.rawScopeFiltered,
|
||||
scopedReferenceCount: scopedReferences.length,
|
||||
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
|
||||
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/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 = "knowledge-rag-fixtures-7-50";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task538-knowledge-rag-source-scope-api-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_KNOWLEDGE_RAG_SCOPE_TIMEOUT_MS || 240_000);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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()}`);
|
||||
assert(result.ok, `knowledge-rag status 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function entryFor(statusPayload, sourcePath) {
|
||||
const entries = statusPayload?.registry?.entries;
|
||||
return Array.isArray(entries) ? entries.find((entry) => entry.sourceRootRelativePath === sourcePath) : null;
|
||||
}
|
||||
|
||||
async function waitForIndexed(context, sourcePaths) {
|
||||
const startedAt = Date.now();
|
||||
let lastStatus = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
lastStatus = await status(context);
|
||||
const allIndexed = sourcePaths.every((sourcePath) => {
|
||||
const entry = entryFor(lastStatus, sourcePath);
|
||||
return entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs;
|
||||
});
|
||||
if (allIndexed) return lastStatus;
|
||||
await sleep(5_000);
|
||||
}
|
||||
throw new Error(`等待 source scope fixture 入库超时: ${JSON.stringify({ sourcePaths, lastStatus }, null, 2).slice(0, 4000)}`);
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePaths) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sources: sourcePaths.map((sourcePath) => ({ sourcePath })),
|
||||
});
|
||||
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function query(context, queryText, sourcePaths) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: queryText,
|
||||
mode: "mix",
|
||||
topK: 12,
|
||||
chunkTopK: 12,
|
||||
includeChunkContent: true,
|
||||
sourcePaths,
|
||||
});
|
||||
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function deleteSource(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/delete-source`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
});
|
||||
assert(result.ok, `knowledge-rag delete-source 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
|
||||
const marker = `SOURCE SCOPE API RAG ${Date.now()}`;
|
||||
const sourceA = `${FIXTURE_DIR}/scope-api-alpha-${Date.now()}.md`;
|
||||
const sourceB = `${FIXTURE_DIR}/scope-api-beta-${Date.now()}.md`;
|
||||
fs.writeFileSync(path.join(ROOT_PATH, sourceA), `# Scope API Alpha\n${marker}\nOnly alpha source should remain after API scope filter.\n`, "utf8");
|
||||
fs.writeFileSync(path.join(ROOT_PATH, sourceB), `# Scope API Beta\n${marker}\nBeta source must be filtered when sourcePaths targets alpha.\n`, "utf8");
|
||||
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
await ingest(context, [sourceA, sourceB]);
|
||||
const indexedStatus = await waitForIndexed(context, [sourceA, sourceB]);
|
||||
const scoped = await query(context, marker, [sourceA]);
|
||||
const references = Array.isArray(scoped.references) ? scoped.references : [];
|
||||
assert.equal(scoped.sourceScopeMode, "post_filter_mapped_references", `sourceScopeMode 不正确: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(scoped.rawScopeFiltered, false, `rawScopeFiltered 应为 false: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
|
||||
assert(references.length > 0, `sourcePaths scope 应至少返回 alpha: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
|
||||
assert(
|
||||
references.every((reference) => reference.sourceRootRelativePath === sourceA),
|
||||
`sourcePaths scope 不应返回非 alpha 来源: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
|
||||
);
|
||||
assert(scoped.raw, "HTTP API 仍应保留 raw 供调试调用方使用");
|
||||
|
||||
await deleteSource(context, sourceA);
|
||||
assert(fs.existsSync(path.join(ROOT_PATH, sourceA)), "delete-source 不应删除用户原始 source 文件");
|
||||
const afterDelete = await query(context, marker, [sourceA]);
|
||||
const afterReferences = Array.isArray(afterDelete.references) ? afterDelete.references : [];
|
||||
assert.equal(afterReferences.length, 0, `删除索引后 source-scoped query 不应继续返回 alpha: ${JSON.stringify(afterReferences, null, 2)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
marker,
|
||||
sourceA,
|
||||
sourceB,
|
||||
sourceScopeMode: scoped.sourceScopeMode,
|
||||
rawScopeFiltered: scoped.rawScopeFiltered,
|
||||
scopedReferenceCount: references.length,
|
||||
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
|
||||
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
|
||||
sourceAExistsAfterDelete: fs.existsSync(path.join(ROOT_PATH, sourceA)),
|
||||
afterDeleteReferenceCount: afterReferences.length,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().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);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
|
||||
const TASK535 = path.join(ROOT, "scripts", "task535-page-ai-local-agent-clean-edit-smoke.js");
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task539-local-agent-audit-scope-contract");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function parseLastJsonObject(output) {
|
||||
const trimmed = String(output || "").trim();
|
||||
const start = trimmed.lastIndexOf("\n{");
|
||||
const jsonText = start >= 0 ? trimmed.slice(start + 1) : trimmed;
|
||||
return JSON.parse(jsonText);
|
||||
}
|
||||
|
||||
function parseRunBody(task535Result) {
|
||||
const runRecord = (task535Result.captured || []).find((item) => item.kind === "run");
|
||||
assert(runRecord, "task535 result 缺少 run payload");
|
||||
return JSON.parse(runRecord.body || "{}");
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const task535Output = run("node", [TASK535]);
|
||||
const task535Result = parseLastJsonObject(task535Output);
|
||||
assert.equal(task535Result.ok, true, "task535 clean edit smoke 必须通过");
|
||||
const runBody = parseRunBody(task535Result);
|
||||
const allowedFiles = runBody.targetPackage?.allowedFiles || [];
|
||||
assert.deepEqual(allowedFiles, [task535Result.relativePath], "targetPackage.allowedFiles 应只包含当前目标文件");
|
||||
assert.equal(runBody.targetPackage?.currentFile?.relativePath, task535Result.relativePath, "currentFile 应冻结为当前目标文件");
|
||||
assert.equal(runBody.runTargetSnapshot?.schema, "mnote.page_ai_run_target_snapshot.v1", "run payload 应携带 frozen target snapshot");
|
||||
assert(runBody.runTargetSnapshot?.frozenAt, "frozen target snapshot 应有 frozenAt");
|
||||
if (runBody.agentTargetPackage) {
|
||||
assert.deepEqual(runBody.agentTargetPackage.allowedFiles || [], [task535Result.relativePath], "agentTargetPackage.allowedFiles 应只包含当前目标文件");
|
||||
}
|
||||
assert.equal(task535Result.usedDocumentsSave, false, "clean edit 不应调用 /api/documents/save");
|
||||
assert.equal(task535Result.usedMarkdownEdit, false, "clean edit 不应调用 mnote.doc.markdown_edit");
|
||||
assert.equal(task535Result.usedPageSave, false, "clean edit 不应调用 mnote.page.save");
|
||||
|
||||
const cargoOutput = run("cargo", ["test", "-p", "mnote-web", "local_agent_audit", "--", "--test-threads=1"], {
|
||||
cwd: path.join(ROOT, "rust"),
|
||||
});
|
||||
assert(cargoOutput.includes("local_agent_audit_allowed_files_override_folder_context"), "cargo test 应覆盖 allowedFiles 优先于 folder context");
|
||||
assert(cargoOutput.includes("local_agent_audit_reads_allowed_files_from_agent_run_envelope"), "cargo test 应覆盖 agentRunEnvelope.allowedFiles");
|
||||
assert(cargoOutput.includes("local_agent_audit_event_carries_agent_run_receipt"), "cargo test 应覆盖 receipt auditScope");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task539-local-agent-audit-scope-contract",
|
||||
task535: {
|
||||
resultPath: path.join(ROOT, "tmp", "task535-page-ai-local-agent-clean-edit-smoke", "result.json"),
|
||||
relativePath: task535Result.relativePath,
|
||||
targetAllowedFiles: allowedFiles,
|
||||
frozenAt: runBody.runTargetSnapshot.frozenAt,
|
||||
usedDocumentsSave: task535Result.usedDocumentsSave,
|
||||
usedMarkdownEdit: task535Result.usedMarkdownEdit,
|
||||
usedPageSave: task535Result.usedPageSave,
|
||||
},
|
||||
backendAudit: {
|
||||
command: "cargo test -p mnote-web local_agent_audit -- --test-threads=1",
|
||||
expectedScope: "allowed_files",
|
||||
expectedFileCount: 1,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/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 = "task540-local-folder-event-bus-single-connection-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 documentUrl(root, 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("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
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", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const actorId = "mnote-e2e";
|
||||
const workspaceId = `local-ws:${actorId}:task540`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task540-event-bus-"));
|
||||
const rootUri = fileUrl(root);
|
||||
const relativePath = "EventBus.md";
|
||||
const resourcePath = "Attachment.bin";
|
||||
const filePath = path.join(root, relativePath);
|
||||
const resourceFilePath = path.join(root, resourcePath);
|
||||
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||
fs.writeFileSync(filePath, `# Event Bus\n\ntask540-${suffix}\n`, "utf8");
|
||||
fs.writeFileSync(resourceFilePath, `task540-resource-${suffix}\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",
|
||||
},
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
const sources = [];
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.__mnoteTask540StatRequests = [];
|
||||
window.__mnoteTask540FileProjectionRequests = [];
|
||||
window.__mnoteTask540SidebarProjectionRequests = [];
|
||||
window.fetch = function patchedTask540Fetch(input, init) {
|
||||
const url = typeof input === "string" ? input : String(input?.url || "");
|
||||
if (url.includes("/api/local-folder/files/stat")) {
|
||||
window.__mnoteTask540StatRequests.push(url);
|
||||
}
|
||||
if (url.includes("/api/tree/projections/file")) {
|
||||
window.__mnoteTask540FileProjectionRequests.push(url);
|
||||
}
|
||||
if (url.includes("/api/tree/projections/sidebar")) {
|
||||
window.__mnoteTask540SidebarProjectionRequests.push(url);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
class FakeEventSource {
|
||||
constructor(input) {
|
||||
this.url = String(input || "");
|
||||
this.readyState = 0;
|
||||
this.listeners = new Map();
|
||||
sources.push(this);
|
||||
setTimeout(() => this.dispatch("open", {}), 0);
|
||||
}
|
||||
addEventListener(name, handler) {
|
||||
if (!this.listeners.has(name)) this.listeners.set(name, []);
|
||||
this.listeners.get(name).push(handler);
|
||||
}
|
||||
removeEventListener(name, handler) {
|
||||
const list = this.listeners.get(name) || [];
|
||||
this.listeners.set(name, list.filter((candidate) => candidate !== handler));
|
||||
}
|
||||
dispatch(name, payload) {
|
||||
const event = { data: JSON.stringify(payload || {}), lastEventId: String(payload?.revision || "") };
|
||||
(this.listeners.get(name) || []).forEach((handler) => handler(event));
|
||||
}
|
||||
close() {
|
||||
this.readyState = 2;
|
||||
}
|
||||
}
|
||||
window.EventSource = FakeEventSource;
|
||||
window.__mnoteTask540EventSources = sources;
|
||||
window.__mnoteTask540CompatEvents = [];
|
||||
window.addEventListener("tree:local-folder-watch-batch", (event) => {
|
||||
window.__mnoteTask540CompatEvents.push(event.detail || {});
|
||||
});
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const statRequests = [];
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/local-folder/files/stat")) {
|
||||
statRequests.push(url);
|
||||
}
|
||||
});
|
||||
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_task540_${suffix}`,
|
||||
userId: actorId,
|
||||
workspaceId,
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
permission: "write",
|
||||
recursive: true,
|
||||
capabilities: ["markdown_edit"],
|
||||
source: "user",
|
||||
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: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => window.__mnoteLocalFolderEventBus, null, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const sources = window.__mnoteTask540EventSources || [];
|
||||
return sources.filter((source) => String(source.url || "").includes("/api/local-folder/events")).length === 1;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(() => window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab, null, { timeout: UI_TIMEOUT_MS });
|
||||
const openedResource = await page.evaluate(async ({ rootUriValue, pathValue, workspaceIdValue }) => {
|
||||
const href = new URL("/api/local-folder/resource/read", window.location.origin);
|
||||
href.searchParams.set("rootUri", rootUriValue);
|
||||
href.searchParams.set("path", pathValue);
|
||||
return await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: `local-resource:${pathValue}`,
|
||||
assetId: `task540:${pathValue}`,
|
||||
title: pathValue,
|
||||
fileName: pathValue,
|
||||
kind: "file",
|
||||
sourceKind: "local_folder",
|
||||
rootUri: rootUriValue,
|
||||
workspaceId: workspaceIdValue,
|
||||
path: pathValue,
|
||||
href: href.toString(),
|
||||
});
|
||||
}, { rootUriValue: rootUri, pathValue: resourcePath, workspaceIdValue: workspaceId });
|
||||
assert.equal(openedResource, true, "resource tab 应能打开");
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.querySelector('.mnote-resource-tab-panel[data-resource-path="Attachment.bin"]');
|
||||
return panel?.getAttribute("data-mnote-resource-watch-ready") === "event-bus";
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__mnoteTask540StatRequests = [];
|
||||
window.__mnoteTask540FileProjectionRequests = [];
|
||||
window.__mnoteTask540SidebarProjectionRequests = [];
|
||||
});
|
||||
await page.evaluate(({ rootUriValue, pagePath, assetPath }) => {
|
||||
const detail = {
|
||||
source: "synthetic_page_ai_receipt",
|
||||
reason: "agent_run_receipt",
|
||||
rootUri: rootUriValue,
|
||||
payload: {
|
||||
schema: "mnote.local_folder.watch_batch.v1",
|
||||
source: "agent_run_receipt",
|
||||
rootUri: rootUriValue,
|
||||
revision: "task540-revision",
|
||||
changedPaths: [
|
||||
{ relativePath: pagePath, changeType: "modified" },
|
||||
{ relativePath: assetPath, changeType: "modified" },
|
||||
],
|
||||
affectedParents: [{ relativePath: "", reason: "task540" }],
|
||||
},
|
||||
};
|
||||
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail);
|
||||
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail);
|
||||
}, { rootUriValue: rootUri, pagePath: relativePath, assetPath: resourcePath });
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const events = window.__mnoteTask540CompatEvents || [];
|
||||
return events.some((event) => event?.source === "synthetic_page_ai_receipt" && event?.viaEventBus === true);
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction((pathValue) => {
|
||||
const urls = window.__mnoteTask540StatRequests || [];
|
||||
return urls.some((url) => String(url).includes(encodeURIComponent(pathValue)) || String(url).includes(pathValue));
|
||||
}, resourcePath, { timeout: 3000 }).catch(() => undefined);
|
||||
await page.waitForFunction(() => {
|
||||
const urls = window.__mnoteTask540FileProjectionRequests || [];
|
||||
return urls.length >= 1;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const sources = (window.__mnoteTask540EventSources || [])
|
||||
.filter((source) => String(source.url || "").includes("/api/local-folder/events"))
|
||||
.map((source) => source.url);
|
||||
const compatEvents = window.__mnoteTask540CompatEvents || [];
|
||||
const resourcePanel = document.querySelector('.mnote-resource-tab-panel[data-resource-path="Attachment.bin"]');
|
||||
return {
|
||||
sources,
|
||||
busState: document.documentElement.getAttribute("data-mnote-local-folder-event-bus") || "",
|
||||
connectionCount: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-connections") || "",
|
||||
lastSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "",
|
||||
lastReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
|
||||
compatEventCount: compatEvents.length,
|
||||
lastCompatViaEventBus: Boolean(compatEvents.at(-1)?.viaEventBus),
|
||||
hasPageAiSyntheticCompatEvent: compatEvents.some((event) => (
|
||||
event?.source === "synthetic_page_ai_receipt"
|
||||
&& event?.reason === "agent_run_receipt"
|
||||
&& event?.viaEventBus === true
|
||||
)),
|
||||
resourceWatchReady: resourcePanel?.getAttribute("data-mnote-resource-watch-ready") || "",
|
||||
browserStatRequests: window.__mnoteTask540StatRequests || [],
|
||||
fileProjectionRequests: window.__mnoteTask540FileProjectionRequests || [],
|
||||
sidebarProjectionRequests: window.__mnoteTask540SidebarProjectionRequests || [],
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(result.sources.length, 1, `同一页面应只有一个 local-folder EventSource,实际 ${result.sources.length}`);
|
||||
assert.equal(result.busState, "ready", "event bus diagnostics 未 ready");
|
||||
assert.equal(result.connectionCount, "1", "event bus diagnostics connectionCount 应为 1");
|
||||
assert.equal(result.hasPageAiSyntheticCompatEvent, true, "Page AI synthetic 事件应保留 source/reason 并通过 bus 派发兼容事件");
|
||||
assert.equal(result.lastCompatViaEventBus, true, "兼容 tree:local-folder-watch-batch 必须标记 viaEventBus");
|
||||
assert.equal(result.resourceWatchReady, "event-bus", "resource tab watch 应通过 event bus 准备好");
|
||||
assert(
|
||||
result.browserStatRequests.some((url) => String(url).includes(encodeURIComponent(resourcePath)) || String(url).includes(resourcePath)),
|
||||
"resource-changed 应触发当前资源 stat 刷新",
|
||||
);
|
||||
assert.equal(result.fileProjectionRequests.length, 1, "同 tick 两个 receipt 应只触发一次 filetree parent projection 刷新");
|
||||
assert.equal(result.sidebarProjectionRequests.length, 0, "content-only receipt 不应触发 sidebar projection 刷新");
|
||||
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, root, result, statRequests }, null, 2)}\n`, "utf8");
|
||||
console.log(`[${TASK}] ok`, RESULT_PATH);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: false, error: error.stack || String(error) }, null, 2)}\n`, "utf8");
|
||||
console.error(`[${TASK}] failed`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/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 TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task541-page-aggregate-local-first-hard-guard-smoke");
|
||||
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"]
|
||||
.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 = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => {
|
||||
request.destroy();
|
||||
retry();
|
||||
});
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`server_not_ready: ${url}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function actorHeaders(actorId) {
|
||||
return {
|
||||
"content-type": "application/json",
|
||||
"x-mnote-actor-id": actorId,
|
||||
"x-mnote-actor-type": "user",
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(baseUrl, actorId, pathname, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||
method: options.method || "GET",
|
||||
headers: actorHeaders(actorId),
|
||||
body: options.body == null ? undefined : JSON.stringify(options.body),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
assert(response.ok, `${options.method || "GET"} ${pathname} failed ${response.status}: ${JSON.stringify(payload)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(baseUrl, rootUri, relativePath) {
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function quickLogin(page, baseUrl) {
|
||||
await page.goto(`${baseUrl}/auth`, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLoginButton.count()) {
|
||||
await quickLoginButton.click({ timeout: TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname === "/", { timeout: TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-page-aggregate-hard-guard-"));
|
||||
const actorId = `task541-${process.pid}-${Date.now()}`;
|
||||
const relativePath = "README.md";
|
||||
const managedRoot = path.join(dataRoot, "users", actorId, "workspaces", "my-space");
|
||||
const markdownPath = path.join(managedRoot, relativePath);
|
||||
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
let browser = null;
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, 90_000);
|
||||
const created = await requestJson(baseUrl, actorId, "/api/local-folder/workspaces/default", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
const rootUri = created.workspace.rootUri;
|
||||
fs.mkdirSync(path.dirname(markdownPath), { recursive: true });
|
||||
fs.writeFileSync(markdownPath, "# Local Guard\n\nlocal-first browser hard guard\n", "utf8");
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 860 },
|
||||
extraHTTPHeaders: actorHeaders(actorId),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await quickLogin(page, baseUrl);
|
||||
await page.goto(documentUrl(baseUrl, rootUri, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
const editorRoot = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await editorRoot.waitFor({ state: "visible", timeout: TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return root?.getAttribute("data-runtime-editor-status") === "ready"
|
||||
&& root?.getAttribute("data-mnote-page-body-source") === "page_aggregate.block_document";
|
||||
}, null, { timeout: TIMEOUT_MS });
|
||||
|
||||
const diagnostic = await page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror");
|
||||
return {
|
||||
status: root?.getAttribute("data-runtime-editor-status") || "",
|
||||
pageBodySource: root?.getAttribute("data-mnote-page-body-source") || "",
|
||||
localCompatFallback: root?.getAttribute("data-mnote-page-body-local-compat-fallback") || "",
|
||||
hardGuard: root?.getAttribute("data-mnote-page-body-hard-guard") || "",
|
||||
projectionSource: root?.getAttribute("data-mnote-projection-source") || "",
|
||||
blockProjectionVersion: root?.getAttribute("data-mnote-block-projection-version") || "",
|
||||
text: editor?.textContent || "",
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(diagnostic.pageBodySource, "page_aggregate.block_document");
|
||||
assert.equal(diagnostic.localCompatFallback, "false");
|
||||
assert.equal(diagnostic.hardGuard, "local_ok");
|
||||
assert.equal(diagnostic.projectionSource, "local_markdown.content");
|
||||
assert(diagnostic.blockProjectionVersion, "local-first blockProjectionVersion 应有诊断值");
|
||||
assert.match(diagnostic.text, /local-first browser hard guard/);
|
||||
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ baseUrl, rootUri, relativePath, diagnostic }, null, 2)}\n`, "utf8");
|
||||
console.log(`task541 page aggregate local-first hard guard smoke passed ${RESULT_PATH}`);
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
server.kill("SIGINT");
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
if (server.exitCode == null) {
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user