feat(rag): replace LiteParse flows with LightRAG provider
This commit is contained in:
@@ -36,7 +36,7 @@ node scripts/task490-runtime-surfaces-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`
|
||||
- local-folder OCR:`task526-local-folder-ocr-api-smoke.js` 覆盖图片 resource tab 手动 OCR、工具条状态、mock OCR API、sidecar 落盘、jobs/status/read、全局 OCR 任务栏 / 任务抽屉、`includeOcr=true` 搜索 owner page、显式插入 OCR 链接和 OCR sidecar Markdown resource tab 打开;`TASK520_OCR_CONTEXT=1 node scripts/task520-page-ai-raw-resource-target-smoke.js` 覆盖 Page AI active image/PDF resource target 读取 OCR sidecar context。
|
||||
- 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 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`
|
||||
|
||||
@@ -44,11 +44,9 @@ function isWriteMnoteTool(toolName) {
|
||||
'mnote.context.snapshot',
|
||||
'mnote.context.resolve_target',
|
||||
'mnote.context.read_current_page',
|
||||
'mnote.evidence.search',
|
||||
'mnote.evidence.read',
|
||||
'mnote.evidence.open',
|
||||
'mnote.index.status',
|
||||
'mnote.index.refresh',
|
||||
'mnote.knowledge_rag.status',
|
||||
'mnote.knowledge_rag.query',
|
||||
'mnote.knowledge_rag.open_reference',
|
||||
'mnote.doc.fetch',
|
||||
'mnote.page.get',
|
||||
'mnote.block.fetch',
|
||||
@@ -248,8 +246,11 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
|
||||
if (!promptWithEnvelope.includes('native file tools')) {
|
||||
throw new Error('selftest expected prompt to instruct native file tools');
|
||||
}
|
||||
if (!promptWithEnvelope.includes('Do not use MNote doc/page write tools for ordinary local Markdown edits.')) {
|
||||
throw new Error('selftest expected prompt to forbid MNote doc/page write tools for ordinary local Markdown edits');
|
||||
}
|
||||
const evidencePayload = buildMnoteToolPayload(
|
||||
'mnote.evidence.search',
|
||||
'mnote.knowledge_rag.query',
|
||||
{ query: 'ResourceBodyToken' },
|
||||
{
|
||||
workspaceId: 'ws_local',
|
||||
@@ -266,8 +267,8 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
|
||||
if (evidencePayload.rootUri !== 'file:///tmp/mnote-local') {
|
||||
throw new Error('selftest expected evidence payload to inherit local root context');
|
||||
}
|
||||
if (isWriteMnoteTool('mnote.evidence.search')) {
|
||||
throw new Error('selftest expected evidence search to be read-only');
|
||||
if (isWriteMnoteTool('mnote.knowledge_rag.query')) {
|
||||
throw new Error('selftest expected knowledge RAG query to be read-only');
|
||||
}
|
||||
const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } });
|
||||
if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') {
|
||||
@@ -502,12 +503,9 @@ const MNOTE_TOOL_NAMES = [
|
||||
'mnote.context.snapshot',
|
||||
'mnote.context.resolve_target',
|
||||
'mnote.context.read_current_page',
|
||||
'mnote.evidence.search',
|
||||
'mnote.evidence.read',
|
||||
'mnote.evidence.open',
|
||||
'mnote.index.status',
|
||||
'mnote.index.refresh',
|
||||
'mnote.index.update_settings',
|
||||
'mnote.knowledge_rag.status',
|
||||
'mnote.knowledge_rag.query',
|
||||
'mnote.knowledge_rag.open_reference',
|
||||
];
|
||||
|
||||
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||
@@ -515,12 +513,9 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||
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_evidence_search: 'mnote.evidence.search',
|
||||
mnote_evidence_read: 'mnote.evidence.read',
|
||||
mnote_evidence_open: 'mnote.evidence.open',
|
||||
mnote_index_status: 'mnote.index.status',
|
||||
mnote_index_refresh: 'mnote.index.refresh',
|
||||
mnote_index_update_settings: 'mnote.index.update_settings',
|
||||
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) {
|
||||
@@ -602,117 +597,65 @@ tools.register({
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_evidence_search',
|
||||
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。查询语言与资料语言可能不一致时,先在提示层做轻量多语关键词扩展,再用简短关键词检索。',
|
||||
name: 'mnote_knowledge_rag_status',
|
||||
description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '要搜索的问题或关键词' },
|
||||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||
targetDocumentId: { type: 'string', description: '可选,限制到当前文档' },
|
||||
includeResources: { type: 'boolean', description: '是否包含附件/资源标题' },
|
||||
includeOcr: { type: 'boolean', description: '是否包含 OCR/source-map 证据' },
|
||||
mode: { type: 'string', enum: ['hybrid', 'tree', 'graph'], description: '检索模式' },
|
||||
topK: { type: 'integer', description: '最多返回结果数' },
|
||||
scope: { type: 'object', description: '完整 EvidenceSearchScope,提供时优先使用' },
|
||||
},
|
||||
},
|
||||
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 会限制在这些来源内。',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_search, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_evidence_read',
|
||||
description: '按 EvidenceLocator 读取原文证据及周边上下文。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
|
||||
context: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
beforeBlocks: { type: 'integer' },
|
||||
afterBlocks: { type: 'integer' },
|
||||
includeSectionSummary: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['locator'],
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_read, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_evidence_open',
|
||||
description: '把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
|
||||
},
|
||||
required: ['locator'],
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_open, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_index_status',
|
||||
description: '查看 MNote 本地索引范围、缓存文件状态、文档数和 evidence block 数。',
|
||||
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_index_status, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_index_refresh',
|
||||
description: '按当前有效范围重建 MNote 本地搜索/evidence 缓存,不修改 Markdown 正文。',
|
||||
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_index_refresh, args),
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_query, args),
|
||||
parallelSafe: false,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'mnote_index_update_settings',
|
||||
description: '新增或删除 MNote 本地索引范围;includePaths 为空表示删除当前用户范围。',
|
||||
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,可省略并使用当前上下文' },
|
||||
includePaths: { type: 'array', items: { type: 'string' }, description: 'root 内相对路径列表,空数组表示删除范围' },
|
||||
scheduleMode: { type: 'string', enum: ['manual', 'daily', 'weekly', 'monthly'], description: '刷新计划' },
|
||||
scheduleTime: { type: 'string', description: 'HH:mm' },
|
||||
scheduleDate: { type: 'string', description: '可选日期' },
|
||||
runOnChange: { type: 'boolean', description: '文件变化时是否自动刷新' },
|
||||
dryRun: { type: 'boolean', description: 'true 只返回计划;false 写入设置并刷新索引' },
|
||||
idempotencyKey: { type: 'string', description: '写入幂等键' },
|
||||
},
|
||||
required: ['includePaths', 'dryRun', 'idempotencyKey'],
|
||||
required: ['filePath'],
|
||||
},
|
||||
readOnly: false,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_update_settings, args),
|
||||
parallelSafe: false,
|
||||
readOnly: true,
|
||||
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_open_reference, args),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
// ── Session Store ────────────────────────────────────
|
||||
@@ -751,10 +694,12 @@ onRequest('session/new', async (params) => {
|
||||
'You are a helpful AI assistant inside MNote.',
|
||||
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
|
||||
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
|
||||
'Do not use MNote doc/page write tools for ordinary local Markdown edits.',
|
||||
'Final answers must be direct user-facing answers. Do not narrate tool use, search steps, plans, or internal process; do not say phrases like "let me search", "I found", "I will check", or "让我".',
|
||||
'<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-local-index — Search local documents with clickable evidence locators and manage local index scopes. When the query language may differ from the corpus language, infer likely corpus terms from filenames/titles/domain context, expand 2-6 concise multilingual keywords, and call mnote_evidence_search with the best short keyword queries. Do not assume the answer language from the corpus; answer in the user language and cite only retrieved evidence.',
|
||||
'- 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-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>',
|
||||
|
||||
@@ -120,9 +120,7 @@ async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task520-raw-target-"));
|
||||
const rootUri = fileUrl(root);
|
||||
const pagePath = "Page.md";
|
||||
const expectOcrContext = process.env.TASK520_OCR_CONTEXT === "1";
|
||||
const rawPath = expectOcrContext ? "Page/photo.png" : "Page/notes.txt";
|
||||
const ocrPath = "Page.ocr/photo.png.ocr.md";
|
||||
const rawPath = "Page/notes.txt";
|
||||
const documentId = localMdDocumentId(pagePath);
|
||||
const captured = [];
|
||||
let caughtError = null;
|
||||
@@ -130,41 +128,7 @@ async function main() {
|
||||
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
|
||||
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||
fs.writeFileSync(path.join(root, pagePath), "# Page\n\nTask520 page\n", "utf8");
|
||||
if (expectOcrContext) {
|
||||
fs.writeFileSync(path.join(root, rawPath), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
|
||||
fs.mkdirSync(path.join(root, "Page.ocr"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ocrPath),
|
||||
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page/photo.png\nsource_root_relative_path: Page/photo.png\nsource_size: 6\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nTask520 OCR sidecar context text\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".mnote", "ocr-index.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
entries: {
|
||||
[rawPath]: {
|
||||
jobId: "ocr_task520",
|
||||
ownerDocumentId: documentId,
|
||||
ownerDocumentPath: pagePath,
|
||||
sourceRootRelativePath: rawPath,
|
||||
ocrRootRelativePath: ocrPath,
|
||||
provider: "mock",
|
||||
modelVersion: "vlm",
|
||||
status: "done",
|
||||
sourceSize: 6,
|
||||
sourceMtimeMs: 1,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 1,
|
||||
plainTextPreview: "Task520 OCR sidecar context text",
|
||||
},
|
||||
},
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
|
||||
}
|
||||
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
@@ -323,16 +287,12 @@ async function main() {
|
||||
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, rawPath, `targetPackage currentFile 应指向 raw resource: ${JSON.stringify(runBody.targetPackage)}`);
|
||||
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `currentFile objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
|
||||
assert(runBody.targetPackage?.allowedFiles?.includes(rawPath), `allowedFiles 应只包含 raw resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
|
||||
if (expectOcrContext) {
|
||||
assert.strictEqual(activeEditorRef.ocrContext?.source, "local_ocr_sidecar", `active_editor 应携带 OCR sidecar context: ${JSON.stringify(activeEditorRef)}`);
|
||||
assert.strictEqual(activeEditorRef.ocrContext?.ocrRootRelativePath, ocrPath, `active_editor OCR path 不正确: ${JSON.stringify(activeEditorRef.ocrContext)}`);
|
||||
assert(activeEditorRef.ocrContext?.plainTextPreview?.includes("Task520 OCR sidecar context text"), `active_editor OCR preview 缺失: ${JSON.stringify(activeEditorRef.ocrContext)}`);
|
||||
assert.strictEqual(runBody.targetPackage?.ocrContext?.ocrRootRelativePath, ocrPath, `targetPackage 应携带 OCR sidecar context: ${JSON.stringify(runBody.targetPackage)}`);
|
||||
assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, ocrPath, `currentFile 应携带 OCR sidecar path: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
|
||||
}
|
||||
assert.strictEqual(activeEditorRef.ocrContext, undefined, `OCR sidecar context 已退役,active_editor 不应携带 ocrContext: ${JSON.stringify(activeEditorRef)}`);
|
||||
assert.strictEqual(runBody.targetPackage?.ocrContext, undefined, `OCR sidecar context 已退役,targetPackage 不应携带 ocrContext: ${JSON.stringify(runBody.targetPackage)}`);
|
||||
assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, undefined, `OCR sidecar path 已退役,currentFile 不应携带 ocrRootRelativePath: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
|
||||
|
||||
const screenshot = await saveScreenshot(page, "01-raw-resource-target");
|
||||
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, ocrPath: expectOcrContext ? ocrPath : "", screenshot, captured };
|
||||
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, screenshot, captured };
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,436 +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 { ensureAuthenticated, UI_TIMEOUT_MS } = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const TASK = "task526-local-folder-ocr-api-smoke";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath.split(path.sep).map((part, index) => (
|
||||
index === 0 ? "" : encodeURIComponent(part)
|
||||
)).join("/")}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${Buffer.from(relativePath, "utf8")
|
||||
.toString("hex")
|
||||
.replace(/../g, (hex) => {
|
||||
const code = Number.parseInt(hex, 16);
|
||||
const ch = String.fromCharCode(code);
|
||||
return /[A-Za-z0-9._-]/.test(ch) ? ch : `~${hex.toUpperCase()}`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
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) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task526`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "ocr"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function writeResult(payload) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function ensureDocumentVisible(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
if (new URL(page.url()).pathname === "/auth") {
|
||||
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
await quickLogin.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task526-ocr-"));
|
||||
const actorId = "mnote-e2e";
|
||||
const workspaceId = `local-ws:${actorId}:task526`;
|
||||
const relativePath = "docs/Page.md";
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const sourceRootRelativePath = "docs/Page.assets/photo.png";
|
||||
const failedSourceRootRelativePath = "docs/Page.assets/photo-failed.png";
|
||||
const ocrToken = "TASK526_OCR_TOKEN";
|
||||
writeWorkspaceManifest(root, actorId);
|
||||
fs.mkdirSync(path.join(root, "docs", "Page.assets"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, relativePath), "# OCR Page\n\n\n", "utf8");
|
||||
const tinyPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8FQOQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
fs.writeFileSync(path.join(root, sourceRootRelativePath), tinyPng);
|
||||
fs.writeFileSync(path.join(root, failedSourceRootRelativePath), tinyPng);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const screenshots = {};
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
await ensureDocumentVisible(page, root, relativePath);
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror img');
|
||||
return image instanceof HTMLImageElement
|
||||
&& image.complete
|
||||
&& image.naturalWidth > 0
|
||||
&& image.src.includes("Page.assets%2Fphoto.png")
|
||||
&& !image.src.includes("%3C")
|
||||
&& !image.src.includes("%3E");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
screenshots.page = path.join(OUTPUT_DIR, "01-page.png");
|
||||
await page.screenshot({ path: screenshots.page, fullPage: true });
|
||||
|
||||
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId }) => {
|
||||
window.__MNOTE_LOCAL_OCR_PROVIDER = "mock";
|
||||
const title = sourceRootRelativePath.split("/").filter(Boolean).pop() || "photo.png";
|
||||
const fileUrl = new URL("/api/local-folder/files/open", window.location.origin);
|
||||
fileUrl.searchParams.set("rootUri", rootUri);
|
||||
fileUrl.searchParams.set("path", sourceRootRelativePath);
|
||||
const opened = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: `local-file:${sourceRootRelativePath}`,
|
||||
assetId: `local-file:${sourceRootRelativePath}`,
|
||||
title,
|
||||
fileName: title,
|
||||
kind: "image",
|
||||
rootUri,
|
||||
path: sourceRootRelativePath,
|
||||
href: fileUrl.toString(),
|
||||
documentId,
|
||||
ownerDocumentId: documentId,
|
||||
workspaceId,
|
||||
sourceKind: "local_folder",
|
||||
});
|
||||
if (!opened) throw new Error("OCR source image resource tab did not open");
|
||||
}, {
|
||||
rootUri: fileUrl(root),
|
||||
documentId,
|
||||
sourceRootRelativePath,
|
||||
workspaceId,
|
||||
});
|
||||
await page.locator('[data-mnote-resource-tab-panel]:not([hidden]) [data-testid="mnote-local-ocr-toolbar"]').first().waitFor({
|
||||
state: "detached",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const watchBatchBeforeOcr = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-local-folder-watch-batch-applied") || "");
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
||||
const topbarOcrButtonInfo = await page.getByTestId("mnote-local-ocr-task-toggle").evaluate((node) => {
|
||||
const topbar = node.closest(".wolai-topbar-actions");
|
||||
return {
|
||||
inTopbar: Boolean(topbar),
|
||||
action: node.getAttribute("data-mnote-action") || "",
|
||||
label: node.getAttribute("aria-label") || "",
|
||||
text: node.textContent || "",
|
||||
badge: node.querySelector("[data-mnote-local-ocr-task-count]")?.textContent || "",
|
||||
};
|
||||
});
|
||||
assert.equal(topbarOcrButtonInfo.inTopbar, true, `OCR 设置入口应位于右上角 topbar: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
||||
assert.equal(topbarOcrButtonInfo.action, "open-ocr-settings", `OCR 顶栏按钮应打开设置: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
||||
await page.waitForFunction(
|
||||
(sourcePath) => {
|
||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||
return row && row.getAttribute("data-mnote-local-ocr-task-status") === "done";
|
||||
},
|
||||
sourceRootRelativePath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-local-ocr-settings-action="tasks"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const uiOcrPath = await page.evaluate(async ({ rootUri, sourceRootRelativePath }) => {
|
||||
const url = new URL("/api/local-folder/ocr/status", window.location.origin);
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("sourceRootRelativePath", sourceRootRelativePath);
|
||||
const response = await fetch(url.toString(), { cache: "no-store", headers: { accept: "application/json" } });
|
||||
const payload = await response.json().catch(() => null);
|
||||
return payload?.job?.ocrRootRelativePath || "";
|
||||
}, {
|
||||
rootUri: fileUrl(root),
|
||||
sourceRootRelativePath,
|
||||
});
|
||||
assert(uiOcrPath && uiOcrPath.endsWith(".ocr.md"), `UI OCR path invalid: ${uiOcrPath}`);
|
||||
await page.waitForFunction(
|
||||
({ before, ocrPath }) => {
|
||||
const root = document.documentElement;
|
||||
const marker = root.getAttribute("data-mnote-local-ocr-filetree-refresh") || "";
|
||||
const applied = root.getAttribute("data-mnote-local-folder-watch-batch-applied") || "";
|
||||
return marker === ocrPath && applied && applied !== before;
|
||||
},
|
||||
{ before: watchBatchBeforeOcr, ocrPath: uiOcrPath },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
screenshots.ocrToolbar = path.join(OUTPUT_DIR, "02-ocr-toolbar.png");
|
||||
await page.screenshot({ path: screenshots.ocrToolbar, fullPage: true });
|
||||
|
||||
const failedOcrRoute = async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback();
|
||||
const body = route.request().postDataJSON();
|
||||
if (body?.sourceRootRelativePath !== failedSourceRootRelativePath) return route.fallback();
|
||||
return route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: false, error: { message: "local_ocr_job_failed_401" } }),
|
||||
});
|
||||
};
|
||||
await page.route("**/api/local-folder/ocr/jobs", failedOcrRoute);
|
||||
await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId }) => {
|
||||
window.__MNOTE_LOCAL_OCR_PROVIDER = "mineru";
|
||||
const title = sourceRootRelativePath.split("/").filter(Boolean).pop() || "photo-failed.png";
|
||||
const fileUrl = new URL("/api/local-folder/files/open", window.location.origin);
|
||||
fileUrl.searchParams.set("rootUri", rootUri);
|
||||
fileUrl.searchParams.set("path", sourceRootRelativePath);
|
||||
const opened = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: `local-file:${sourceRootRelativePath}`,
|
||||
assetId: `local-file:${sourceRootRelativePath}`,
|
||||
title,
|
||||
fileName: title,
|
||||
kind: "image",
|
||||
rootUri,
|
||||
path: sourceRootRelativePath,
|
||||
href: fileUrl.toString(),
|
||||
documentId,
|
||||
ownerDocumentId: documentId,
|
||||
workspaceId,
|
||||
sourceKind: "local_folder",
|
||||
});
|
||||
if (!opened) throw new Error("OCR failed source image resource tab did not open");
|
||||
}, {
|
||||
rootUri: fileUrl(root),
|
||||
documentId,
|
||||
sourceRootRelativePath: failedSourceRootRelativePath,
|
||||
workspaceId,
|
||||
});
|
||||
const failedImageTab = page.locator('.mnote-main-tab[data-mnote-tab-kind="image"]', { hasText: "photo-failed.png" }).first();
|
||||
await failedImageTab.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await failedImageTab.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const active = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="image"] .mnote-main-tab-title');
|
||||
return active && (active.textContent || "").includes("photo-failed.png");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('[data-mnote-resource-tab-panel]:not([hidden]) [data-testid="mnote-local-ocr-toolbar"]').first().waitFor({
|
||||
state: "detached",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(sourcePath) => {
|
||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||
return row
|
||||
&& row.getAttribute("data-mnote-local-ocr-task-status") === "failed"
|
||||
&& (row.textContent || "").includes("local_ocr_job_failed_401");
|
||||
},
|
||||
failedSourceRootRelativePath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
screenshots.ocrFailedTask = path.join(OUTPUT_DIR, "04-ocr-failed-task.png");
|
||||
await page.screenshot({ path: screenshots.ocrFailedTask, fullPage: true });
|
||||
await page.unroute("**/api/local-folder/ocr/jobs", failedOcrRoute);
|
||||
|
||||
const openResourceResult = await page.evaluate(async ({ rootUri, documentId, ocrRootRelativePath, workspaceId }) => {
|
||||
const runtime = window.__mnoteDocumentPaneRuntime;
|
||||
if (!runtime || typeof runtime.openResourceInActiveTab !== "function") {
|
||||
throw new Error("缺少 openResourceInActiveTab runtime");
|
||||
}
|
||||
const title = ocrRootRelativePath.split("/").filter(Boolean).pop() || "OCR";
|
||||
return await runtime.openResourceInActiveTab({
|
||||
kind: "markdown",
|
||||
title,
|
||||
path: ocrRootRelativePath,
|
||||
objectIdentity: `local-ocr:${ocrRootRelativePath}`,
|
||||
assetId: `local-ocr:${ocrRootRelativePath}`,
|
||||
documentId,
|
||||
ownerDocumentId: documentId,
|
||||
workspaceId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
resourceKind: "markdown",
|
||||
});
|
||||
}, {
|
||||
rootUri: fileUrl(root),
|
||||
documentId,
|
||||
ocrRootRelativePath: uiOcrPath,
|
||||
workspaceId,
|
||||
});
|
||||
assert.equal(openResourceResult, true, "OCR sidecar resource tab should open");
|
||||
await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const active = document.querySelector('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
|
||||
return active && (active.textContent || "").includes("OCR UI smoke text");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(async (error) => {
|
||||
const debug = await page.evaluate(() => ({
|
||||
activeTab: document.querySelector('.mnote-main-tab.is-active')?.outerHTML || '',
|
||||
activePanels: Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel]:not([hidden])')).map((panel) => ({
|
||||
kind: panel.getAttribute('data-resource-kind') || '',
|
||||
objectIdentity: panel.getAttribute('data-mnote-object-identity') || '',
|
||||
text: (panel.textContent || '').slice(0, 200),
|
||||
html: panel.innerHTML.slice(0, 500),
|
||||
})),
|
||||
marker: document.documentElement.getAttribute('data-mnote-local-ocr-filetree-open') || '',
|
||||
}));
|
||||
throw new Error(`${error.message}; debug=${JSON.stringify(debug)}`);
|
||||
});
|
||||
const fileTreeOpenResult = await page.evaluate((ocrRootRelativePath) => {
|
||||
const parentPath = ocrRootRelativePath.split("/").slice(0, -1).join("/");
|
||||
const parentRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(parentPath)}"]`);
|
||||
if (!(parentRow instanceof HTMLElement)) return { ok: false, reason: "ocr_parent_row_missing", parentPath };
|
||||
if (parentRow.getAttribute("aria-expanded") !== "true") {
|
||||
const toggle = parentRow.querySelector('[data-rust-action="toggle"]');
|
||||
if (toggle instanceof HTMLElement) toggle.click();
|
||||
}
|
||||
return { ok: true, parentPath };
|
||||
}, uiOcrPath);
|
||||
assert.equal(fileTreeOpenResult.ok, true, `OCR sidecar parent should exist in filetree: ${JSON.stringify(fileTreeOpenResult)}`);
|
||||
const ocrFileTreeOpenResult = await page.waitForFunction(
|
||||
(ocrRootRelativePath) => {
|
||||
const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'));
|
||||
const exact = rows.find((row) => row.getAttribute("data-local-relative-path") === ocrRootRelativePath);
|
||||
const fileName = ocrRootRelativePath.split("/").filter(Boolean).pop() || ocrRootRelativePath;
|
||||
const fallback = rows.find((row) => {
|
||||
const relativePath = row.getAttribute("data-local-relative-path") || "";
|
||||
return relativePath.endsWith(`/${fileName}`) || relativePath === fileName || (row.textContent || "").includes(fileName);
|
||||
});
|
||||
const target = exact || fallback;
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
target.click();
|
||||
return {
|
||||
ok: true,
|
||||
exact: Boolean(exact),
|
||||
relativePath: target.getAttribute("data-local-relative-path") || "",
|
||||
};
|
||||
},
|
||||
uiOcrPath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).then((handle) => handle.jsonValue());
|
||||
assert.equal(ocrFileTreeOpenResult.ok, true, `OCR sidecar filetree row should open: ${JSON.stringify(ocrFileTreeOpenResult)}`);
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-local-ocr-filetree-open") === "resource-tab",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const active = document.querySelector('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
|
||||
return active && (active.textContent || "").includes("OCR UI smoke text");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(async (error) => {
|
||||
const debug = await page.evaluate(() => ({
|
||||
activeTab: document.querySelector('.mnote-main-tab.is-active')?.outerHTML || '',
|
||||
activePanels: Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel]:not([hidden])')).map((panel) => ({
|
||||
kind: panel.getAttribute('data-resource-kind') || '',
|
||||
objectIdentity: panel.getAttribute('data-mnote-object-identity') || '',
|
||||
text: (panel.textContent || '').slice(0, 200),
|
||||
html: panel.innerHTML.slice(0, 500),
|
||||
})),
|
||||
marker: document.documentElement.getAttribute('data-mnote-local-ocr-filetree-open') || '',
|
||||
}));
|
||||
throw new Error(`${error.message}; debug=${JSON.stringify(debug)}`);
|
||||
});
|
||||
screenshots.ocrResource = path.join(OUTPUT_DIR, "03-ocr-resource-tab.png");
|
||||
await page.screenshot({ path: screenshots.ocrResource, fullPage: true });
|
||||
await page.evaluate((sourcePath) => {
|
||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||
const clear = row && row.querySelector("[data-mnote-local-ocr-task-clear]");
|
||||
if (!(clear instanceof HTMLButtonElement)) throw new Error("missing OCR clear button");
|
||||
clear.click();
|
||||
}, failedSourceRootRelativePath);
|
||||
await page.waitForFunction(
|
||||
(sourcePath) => !document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`),
|
||||
failedSourceRootRelativePath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const deleteButtonVisible = await page.evaluate((sourcePath) => {
|
||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||
return Boolean(row && row.querySelector("[data-mnote-local-ocr-task-delete]"));
|
||||
}, sourceRootRelativePath);
|
||||
assert.equal(deleteButtonVisible, true, "已完成 OCR 任务应展示删除 OCR 按钮");
|
||||
|
||||
await writeResult({
|
||||
ok: true,
|
||||
task: TASK,
|
||||
root,
|
||||
documentId,
|
||||
ocrRootRelativePath: uiOcrPath,
|
||||
screenshots,
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
task: TASK,
|
||||
root,
|
||||
documentId,
|
||||
ocrRootRelativePath: uiOcrPath,
|
||||
screenshots,
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
screenshots.failure = path.join(OUTPUT_DIR, "failure.png");
|
||||
await page.screenshot({ path: screenshots.failure, fullPage: true }).catch(() => undefined);
|
||||
await writeResult({
|
||||
ok: false,
|
||||
task: TASK,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
root,
|
||||
documentId,
|
||||
screenshots,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,387 +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 { execFileSync } = require("node:child_process");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task528-document-evidence-liteparse-agent-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const ACTOR_ID = "mnote-e2e";
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
const RUN_REASONIX_ACP = process.env.MNOTE_TASK528_SKIP_REASONIX_ACP !== "1";
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath}`;
|
||||
}
|
||||
|
||||
async function fetchJson(pathname, init = {}) {
|
||||
const response = await fetch(`${BASE_URL}${pathname}`, init);
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
assert(
|
||||
response.ok,
|
||||
`${pathname} 请求失败: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
return { payload, response };
|
||||
}
|
||||
|
||||
async function postJson(pathname, data, headers = {}) {
|
||||
return (await fetchJson(pathname, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(data),
|
||||
})).payload;
|
||||
}
|
||||
|
||||
async function putJson(pathname, data, headers = {}) {
|
||||
return (await fetchJson(pathname, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(data),
|
||||
})).payload;
|
||||
}
|
||||
|
||||
async function getText(pathname, headers = {}, timeoutMs = 180_000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}${pathname}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok, `${pathname} 请求失败: ${response.status} ${text.slice(0, 500)}`);
|
||||
return text;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function signInCookie() {
|
||||
const { response } = await fetchJson("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: ACTOR_ID,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const setCookie = response.headers.get("set-cookie") || "";
|
||||
const session = setCookie.match(/mnote_session=[^;]+/u)?.[0];
|
||||
assert(session, `登录响应缺少 mnote_session cookie: ${setCookie}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function createAiGrant(rootUri) {
|
||||
const payload = await postJson("/api/admin/access-policy/grants", {
|
||||
userId: ACTOR_ID,
|
||||
rootUri,
|
||||
permission: "write",
|
||||
recursive: true,
|
||||
capabilities: ["ai"],
|
||||
}, {
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "admin",
|
||||
});
|
||||
assert(payload.grant?.id, "创建 AI 目录授权后缺少 grant id");
|
||||
return payload.grant;
|
||||
}
|
||||
|
||||
function assertEvidenceHit(hit, expected) {
|
||||
assert(hit, `${expected.label} 缺少正文级 PDF evidence 命中`);
|
||||
assert(String(hit.quote || "").includes("Printer test page"), `${expected.label} quote 不包含 PDF 正文 token`);
|
||||
assert.strictEqual(hit.source?.ownerDocumentPath, expected.ownerRel, `${expected.label} ownerDocumentPath`);
|
||||
assert.strictEqual(hit.source?.resourcePath, expected.pdfRel, `${expected.label} resourcePath`);
|
||||
assert.strictEqual(hit.source?.resourceKind, "pdf", `${expected.label} resourceKind`);
|
||||
assert(hit.source?.page, `${expected.label} 缺少 page locator`);
|
||||
assert(hit.source?.bbox, `${expected.label} 缺少 bbox locator`);
|
||||
assert(hit.source?.sourceMapPath, `${expected.label} 缺少 sourceMapPath`);
|
||||
}
|
||||
|
||||
function decodeSseToolPayloads(sse) {
|
||||
return String(sse || "")
|
||||
.split(/\n\n+/u)
|
||||
.map((eventText) => {
|
||||
const eventName = eventText
|
||||
.split(/\n/u)
|
||||
.find((line) => line.startsWith("event:"))
|
||||
?.slice("event:".length)
|
||||
.trim();
|
||||
const dataLines = eventText
|
||||
.split(/\n/u)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart());
|
||||
if (!dataLines.length) return null;
|
||||
try {
|
||||
return { event: eventName || null, payload: JSON.parse(dataLines.join("\n")) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function assertReasonixAcpEvidenceSse(sse) {
|
||||
assert(sse.includes('"tool":"mnote_evidence_search"'), "Reasonix ACP SSE 未出现 mnote_evidence_search 工具调用");
|
||||
assert(sse.includes("event: tool.completed"), "Reasonix ACP evidence 工具未标记为 completed");
|
||||
assert(!sse.includes("event: tool.failed"), "Reasonix ACP evidence 工具被错误标记为 failed");
|
||||
const payloads = decodeSseToolPayloads(sse);
|
||||
const completedTool = payloads.find(({ event, payload }) =>
|
||||
event === "tool.completed" && payload?.status === "completed"
|
||||
);
|
||||
assert(completedTool, "Reasonix ACP SSE 缺少 completed evidence tool payload");
|
||||
const outputText = (completedTool.payload.output || [])
|
||||
.map((item) => item?.content?.text || "")
|
||||
.join("\n");
|
||||
assert(outputText.includes('"quote":"Printer test page"'), "Reasonix ACP 工具结果未返回 PDF 正文 quote");
|
||||
assert(outputText.includes('"page":1'), "Reasonix ACP 工具结果未返回 page locator");
|
||||
assert(outputText.includes("mnote.agent_run_receipt.evidence.v1"), "Reasonix ACP 工具结果缺少 evidence run receipt");
|
||||
}
|
||||
|
||||
async function runReasonixAcpEvidenceCheck(input) {
|
||||
const { workspaceId, rootUri, documentId, actorHeaders } = input;
|
||||
await createAiGrant(rootUri);
|
||||
const sessionId = `task528_reasonix_tools_${Date.now().toString(36)}`;
|
||||
const traceId = `task528-reasonix-tools-${Date.now().toString(36)}`;
|
||||
const run = await postJson("/api/hermes/client/runs", {
|
||||
workspaceId,
|
||||
documentId,
|
||||
sessionId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
agentId: "reasonix",
|
||||
profile: "reasonix",
|
||||
acpRuntime: "reasonix",
|
||||
contextScope: "page",
|
||||
contextRefs: ["current_page", "folder"],
|
||||
allowedRoots: [{ rootUri, permission: "write" }],
|
||||
skillPreferences: {
|
||||
mnote: {
|
||||
"mnote-local-index": true,
|
||||
"mnote-chat-only": false,
|
||||
},
|
||||
},
|
||||
message: "请调用 mnote_evidence_search 搜索 Printer test page,然后用一句中文回答页码和 quote。必须使用工具,不能只说正在搜索。",
|
||||
traceId,
|
||||
pageContext: {
|
||||
contextScope: "page",
|
||||
node: { documentId, title: "EvidenceLive" },
|
||||
aiContext: {
|
||||
schema: "mnote.page_ai_context.v1",
|
||||
workspaceId,
|
||||
documentId,
|
||||
scope: "page",
|
||||
selectedText: "",
|
||||
selectedBlockIds: [],
|
||||
contextBlocks: [],
|
||||
pageText: "",
|
||||
pageXml: `<page id=\"${documentId}\"></page>`,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
}, actorHeaders);
|
||||
assert(run.ok === true && run.runId, `Reasonix ACP run 创建失败: ${JSON.stringify(run)}`);
|
||||
|
||||
const sse = await getText(`/api/hermes/client/events/${encodeURIComponent(run.runId)}`, actorHeaders);
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-tools-events.sse"), sse, "utf8");
|
||||
assertReasonixAcpEvidenceSse(sse);
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-live-run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8");
|
||||
return {
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
eventPath: path.join(OUTPUT_DIR, "reasonix-tools-events.sse"),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-evidence-live-"));
|
||||
const workspaceId = `local-ws:${ACTOR_ID}:task528-evidence`;
|
||||
const rootUri = fileUrl(root);
|
||||
const ownerRel = "EvidenceLive.md";
|
||||
const pdfRel = "assets/default-testpage.pdf";
|
||||
const documentId = localMdDocumentId(ownerRel);
|
||||
const actorHeaders = {
|
||||
"x-mnote-actor-id": ACTOR_ID,
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": workspaceId,
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "assets"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".mnote", "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId,
|
||||
ownerId: ACTOR_ID,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.copyFileSync("/usr/share/cups/data/default-testpage.pdf", path.join(root, pdfRel));
|
||||
fs.writeFileSync(
|
||||
path.join(root, ownerRel),
|
||||
["# Evidence Live", "", "测试正文级 PDF evidence。", "", `[Printer PDF](${pdfRel})`, ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cookie = await signInCookie();
|
||||
const toolsPayload = (await fetchJson("/api/hermes/client/tools?scope=mnote&profile=reasonix", {
|
||||
headers: { cookie },
|
||||
})).payload;
|
||||
const toolNames = (toolsPayload.tools || []).map((tool) => tool.name);
|
||||
for (const name of [
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
"mnote.index.status",
|
||||
"mnote.index.refresh",
|
||||
"mnote.index.update_settings",
|
||||
]) {
|
||||
assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`);
|
||||
}
|
||||
const capabilitiesPayload = (await fetchJson("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=reasonix", {
|
||||
headers: { cookie },
|
||||
})).payload;
|
||||
const mnoteCapabilities = (capabilitiesPayload.categories || []).flatMap((category) => category.capabilities || category.skills || []);
|
||||
assert(
|
||||
mnoteCapabilities.some((capability) => capability.id === "mnote-local-index" && capability.enabled !== false),
|
||||
"Reasonix agent 缺少启用的 mnote-local-index 能力",
|
||||
);
|
||||
|
||||
const settings = await putJson("/api/search/local-index/settings", {
|
||||
workspaceId,
|
||||
rootUri,
|
||||
includePaths: ["."],
|
||||
scheduleMode: "manual",
|
||||
runOnChange: false,
|
||||
}, actorHeaders);
|
||||
assert.strictEqual(settings.ok, true, "local evidence index settings ok");
|
||||
const refresh = await postJson("/api/search/local-index/refresh", { workspaceId, rootUri }, actorHeaders);
|
||||
assert.strictEqual(refresh.ok, true, "local evidence index refresh ok");
|
||||
|
||||
const direct = await postJson("/api/evidence/search", {
|
||||
query: "Printer test page",
|
||||
scope: { workspaceId, rootUri, includeResources: true, includeOcr: true },
|
||||
mode: "hybrid",
|
||||
topK: 5,
|
||||
}, actorHeaders);
|
||||
const directHit = (direct.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
||||
assertEvidenceHit(directHit, { label: "direct", ownerRel, pdfRel });
|
||||
|
||||
const read = await postJson("/api/evidence/read", {
|
||||
locator: directHit.source,
|
||||
context: { beforeBlocks: 1, afterBlocks: 1, includeSectionSummary: true },
|
||||
}, actorHeaders);
|
||||
assert.strictEqual(read.ok, true, "evidence read ok");
|
||||
assert(String(read.quote || "").includes("Printer test page"), "evidence read 未读回 PDF 正文 quote");
|
||||
|
||||
const open = await postJson("/api/evidence/open", { locator: directHit.source }, actorHeaders);
|
||||
assert.strictEqual(open.ok, true, "evidence open ok");
|
||||
assert(open.openAction?.params?.sourceMapPath, "evidence open 缺少 sourceMapPath params");
|
||||
|
||||
const toolEnvelope = await postJson("/api/hermes/tools/mnote/call", {
|
||||
toolName: "mnote.evidence.search",
|
||||
workspaceId,
|
||||
documentId,
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
actorId: ACTOR_ID,
|
||||
profile: "reasonix",
|
||||
sessionId: "task528_evidence_session",
|
||||
runId: "task528_evidence_run",
|
||||
toolCallId: "task528_evidence_tool_call",
|
||||
args: { query: "Printer test page", includeResources: true, includeOcr: true, topK: 5 },
|
||||
}, actorHeaders);
|
||||
assert.strictEqual(toolEnvelope.ok, true, "MNote evidence tool envelope ok");
|
||||
const toolResult = toolEnvelope.result || toolEnvelope;
|
||||
const toolHit = (toolResult.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
||||
assertEvidenceHit(toolHit, { label: "agent-tool", ownerRel, pdfRel });
|
||||
assert(String(toolHit.citationMarkdown || "").includes("](/documents/"), "agent tool 缺少可点击 citationMarkdown");
|
||||
assert(String(toolHit.citationUrl || "").includes("resourceTab="), "agent tool citationUrl 缺少资源 tab 定位参数");
|
||||
assert((toolEnvelope.audit?.evidenceIds || []).includes(toolHit.evidenceId), "agent tool audit 缺少 evidence id");
|
||||
assert.strictEqual(toolEnvelope.audit?.runReceipt?.toolName, "mnote.evidence.search", "run receipt toolName");
|
||||
|
||||
const reasonixAcp = RUN_REASONIX_ACP
|
||||
? await runReasonixAcpEvidenceCheck({ workspaceId, rootUri, documentId, actorHeaders })
|
||||
: { skipped: true };
|
||||
|
||||
execFileSync(process.execPath, ["scripts/reasonix-acp-wrapper.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, MNOTE_REASONIX_ACP_SELFTEST: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const parseMd = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.parse.md");
|
||||
const sourceMap = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.source-map.json");
|
||||
const sqlitePath = path.join(root, ".mnote", "index", "evidence.sqlite");
|
||||
assert(fs.existsSync(parseMd), `缺少 LiteParse parse sidecar: ${parseMd}`);
|
||||
assert(fs.existsSync(sourceMap), `缺少 source-map sidecar: ${sourceMap}`);
|
||||
assert(fs.existsSync(sqlitePath), `缺少 evidence sqlite: ${sqlitePath}`);
|
||||
const ftsCount = Number(execFileSync(
|
||||
"sqlite3",
|
||||
[sqlitePath, "SELECT count(*) FROM evidence_fts WHERE evidence_fts MATCH 'Printer';"],
|
||||
{ encoding: "utf8" },
|
||||
).trim());
|
||||
assert(ftsCount >= 1, `evidence.sqlite FTS 未命中 PDF 正文: ${ftsCount}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
workspaceId,
|
||||
documentId,
|
||||
directEvidenceId: directHit.evidenceId,
|
||||
toolEvidenceId: toolHit.evidenceId,
|
||||
quote: directHit.quote,
|
||||
page: directHit.source.page,
|
||||
bbox: directHit.source.bbox,
|
||||
ownerDocumentPath: directHit.source.ownerDocumentPath,
|
||||
resourcePath: directHit.source.resourcePath,
|
||||
sourceMapPath: directHit.source.sourceMapPath,
|
||||
parseMd,
|
||||
sourceMap,
|
||||
sqlitePath,
|
||||
ftsCount,
|
||||
reasonixTools: toolNames.filter((name) => name.startsWith("mnote.evidence.")),
|
||||
evidenceSkillEnabled: true,
|
||||
receiptToolName: toolEnvelope.audit.runReceipt.toolName,
|
||||
reasonixAcp,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
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,150 @@
|
||||
#!/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 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 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");
|
||||
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/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, executablePath: CHROMIUM_EXECUTABLE_PATH });
|
||||
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
|
||||
try {
|
||||
const auth = 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(auth.ok(), `登录失败: ${auth.status()} ${await auth.text()}`);
|
||||
|
||||
const queryResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, {
|
||||
data: {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: QUERY,
|
||||
mode: "mix",
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
includeChunkContent: true,
|
||||
},
|
||||
});
|
||||
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(String(reference.citationUrl || "").includes("resourceTab="), "query citationUrl 缺少 resourceTab");
|
||||
assert(String(reference.citationMarkdown || "").includes("p."), "query citationMarkdown 缺少页码");
|
||||
|
||||
const openResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/open-reference`, {
|
||||
data: {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
reference: reference.reference,
|
||||
filePath: reference.filePath,
|
||||
chunkId: reference.chunkId,
|
||||
},
|
||||
});
|
||||
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(String(openPayload.reference?.citationUrl || "").includes("resourceTab="), "open_reference citationUrl 缺少 resourceTab");
|
||||
assert(String(openPayload.reference?.citationMarkdown || "").includes("p."), "open_reference citationMarkdown 缺少页码");
|
||||
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
await page.goto(`${BASE_URL}${reference.citationUrl}`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expectedResource) => document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`),
|
||||
EXPECTED_RESOURCE,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForTimeout(3_000);
|
||||
const state = await page.evaluate((expectedResource) => {
|
||||
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
|
||||
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']");
|
||||
return {
|
||||
url: location.href,
|
||||
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
|
||||
activeTabKind: activeTab ? activeTab.getAttribute("data-mnote-tab-kind") : "",
|
||||
panelVisible: !!panel && !panel.hidden,
|
||||
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
|
||||
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
|
||||
inlinePdfVisible: !!inlineViewer,
|
||||
evidencePageRendered: !!evidencePage,
|
||||
evidencePageNumber: evidencePage ? evidencePage.getAttribute("data-page-number") : "",
|
||||
};
|
||||
}, EXPECTED_RESOURCE);
|
||||
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)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: QUERY,
|
||||
expectedResource: EXPECTED_RESOURCE,
|
||||
citationUrl: reference.citationUrl,
|
||||
citationMarkdown: reference.citationMarkdown,
|
||||
page: reference.locator.page,
|
||||
bbox: reference.locator.bbox,
|
||||
openReferenceCitationUrl: openPayload.reference.citationUrl,
|
||||
state,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/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 { 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", "task530-knowledge-rag-page-ai-final-answer-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
|
||||
const CONTROL_PLANE_DB =
|
||||
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const ACTOR_ID = "mnote-e2e";
|
||||
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
|
||||
const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = `file://${ROOT_PATH}`;
|
||||
const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
|
||||
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
|
||||
|
||||
function sqlQuote(value) {
|
||||
return `'${String(value).replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function sqliteExec(sql) {
|
||||
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ensureGrant() {
|
||||
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
|
||||
const now = new Date().toISOString();
|
||||
sqliteExec(`
|
||||
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
||||
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
||||
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
|
||||
VALUES ('grant_task530_knowledge_rag', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureOwnerPage() {
|
||||
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
|
||||
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
|
||||
if (!fs.existsSync(ownerPath)) {
|
||||
fs.writeFileSync(
|
||||
ownerPath,
|
||||
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for LightRAG retrieval.", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: ACTOR_ID,
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
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, initialCount) {
|
||||
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);
|
||||
const content = node?.querySelector(".wolai-page-ai-message-text");
|
||||
return content ? content.textContent || "" : "";
|
||||
}, initialCount);
|
||||
}
|
||||
|
||||
async function newestAssistantLinks(page, initialCount) {
|
||||
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);
|
||||
if (!node) return [];
|
||||
return Array.from(node.querySelectorAll("a")).map((anchor) => ({
|
||||
text: anchor.textContent || "",
|
||||
href: anchor.getAttribute("href") || "",
|
||||
}));
|
||||
}, initialCount);
|
||||
}
|
||||
|
||||
function summarizeRun(body) {
|
||||
return {
|
||||
workspaceId: body.workspaceId || "",
|
||||
documentId: body.documentId || "",
|
||||
sourceKind: body.sourceKind || "",
|
||||
rootUri: body.rootUri || "",
|
||||
agentId: body.agentId || "",
|
||||
profile: body.profile || "",
|
||||
acpRuntime: body.acpRuntime || "",
|
||||
contextRefs: Array.isArray(body.contextRefs)
|
||||
? body.contextRefs.map((item) => (typeof item === "string" ? item : item?.kind || "")).filter(Boolean)
|
||||
: [],
|
||||
allowedRootCount: Array.isArray(body.allowedRoots) ? body.allowedRoots.length : 0,
|
||||
mnoteKnowledgeRagDisabled: body.skillPreferences?.mnote?.["mnote-knowledge-rag"] === false,
|
||||
message: body.message || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
ensureGrant();
|
||||
ensureOwnerPage();
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
|
||||
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const capturedRuns = [];
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
consoleErrors.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
|
||||
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 {
|
||||
await signIn(context);
|
||||
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`);
|
||||
documentUrl.searchParams.set("sourceKind", "local_folder");
|
||||
documentUrl.searchParams.set("rootUri", ROOT_URI);
|
||||
documentUrl.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await selectReasonix(page);
|
||||
|
||||
const assistantCount = await page
|
||||
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
|
||||
.count();
|
||||
const prompt = [
|
||||
"请调用 mnote_knowledge_rag_query 检索资料库。",
|
||||
"问题:scan image start 这份扫描 PDF 里出现了什么关键短语?",
|
||||
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接,不要描述检索过程,不要输出 raw JSON,不要编造页码。",
|
||||
].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 });
|
||||
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(
|
||||
(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);
|
||||
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");
|
||||
},
|
||||
assistantCount,
|
||||
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
|
||||
);
|
||||
|
||||
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(
|
||||
assistantLinks.some((link) => link.text.includes("scan-image-start.pdf") && link.href.includes("resourceTab=")),
|
||||
`可见回答缺少可点击 resourceTab citation: ${JSON.stringify(assistantLinks)}`,
|
||||
);
|
||||
assert(!assistantText.includes("citationMarkdown"), `可见回答泄漏工具字段名: ${assistantText}`);
|
||||
assert(!assistantText.includes('"raw"'), `可见回答泄漏 raw JSON: ${assistantText}`);
|
||||
assert(!assistantText.includes("mnote_knowledge_rag_query"), `可见回答泄漏工具名: ${assistantText}`);
|
||||
assert(!/让我|我来|我先|查询返回|找到了|检索资料库/.test(assistantText), `可见回答包含检索过程叙述: ${assistantText}`);
|
||||
assert(
|
||||
capturedRuns.some((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"),
|
||||
"未捕获到 Reasonix Page AI run",
|
||||
);
|
||||
assert(
|
||||
capturedRuns.every((body) => body.sourceKind === "local_folder" && body.rootUri === ROOT_URI),
|
||||
"Page AI run 未保持 local_folder/rootUri 上下文",
|
||||
);
|
||||
assert(
|
||||
capturedRuns.some((body) => body.skillPreferences?.mnote?.["mnote-knowledge-rag"] !== false),
|
||||
"Page AI run 禁用了 mnote-knowledge-rag",
|
||||
);
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
rootUri: ROOT_URI,
|
||||
documentId: DOCUMENT_ID,
|
||||
assistantText,
|
||||
assistantLinks,
|
||||
capturedRuns: capturedRuns.map(summarizeRun),
|
||||
capturedRunsFullPath: path.join(OUTPUT_DIR, "captured-runs-full.json"),
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "captured-runs-full.json"),
|
||||
`${JSON.stringify(capturedRuns, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
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),
|
||||
capturedRuns,
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const JSZip = require("jszip");
|
||||
const { chromium } = 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 SOURCE_DOCX = process.env.MNOTE_KNOWLEDGE_RAG_DOCX_SOURCE || path.join(process.cwd(), "tmp", "onlyoffice-direct-bridge.docx");
|
||||
const FIXTURE_DIR = "knowledge-rag-fixtures-7-50";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task532-knowledge-rag-docx-ingestion-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "docx-resource-tab.png");
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_KNOWLEDGE_RAG_DOCX_TIMEOUT_MS || 10 * 60_000);
|
||||
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/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function writeDocxFixture(targetPath, marker) {
|
||||
assert(fs.existsSync(SOURCE_DOCX), `缺少 DOCX 源文件: ${SOURCE_DOCX}`);
|
||||
const zip = await JSZip.loadAsync(fs.readFileSync(SOURCE_DOCX));
|
||||
const documentFile = zip.file("word/document.xml");
|
||||
assert(documentFile, `DOCX 缺少 word/document.xml: ${SOURCE_DOCX}`);
|
||||
const originalXml = await documentFile.async("string");
|
||||
const paragraph = `<w:p><w:r><w:t>${xmlEscape(marker)}</w:t></w:r></w:p>`;
|
||||
const nextXml = originalXml.includes("</w:body>")
|
||||
? originalXml.replace("</w:body>", `${paragraph}</w:body>`)
|
||||
: originalXml + paragraph;
|
||||
zip.file("word/document.xml", nextXml);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||||
fs.writeFileSync(targetPath, buffer);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.request.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"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, `knowledge-rag status 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function findRegistryEntry(payload, sourcePath) {
|
||||
const entries = payload?.registry?.entries;
|
||||
return Array.isArray(entries) ? entries.find((entry) => entry.sourceRootRelativePath === sourcePath) : null;
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sources: [{ sourcePath }],
|
||||
});
|
||||
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function waitForIndexed(context, sourcePath) {
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
let lastStatus = null;
|
||||
let lastIngest = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
attempts += 1;
|
||||
lastStatus = await status(context);
|
||||
const entry = findRegistryEntry(lastStatus, sourcePath);
|
||||
if (entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs) {
|
||||
return { entry, status: lastStatus, attempts, lastIngest };
|
||||
}
|
||||
if (!entry || attempts % 6 === 1) {
|
||||
lastIngest = await ingest(context, sourcePath);
|
||||
}
|
||||
await sleep(5_000);
|
||||
}
|
||||
throw new Error(`等待 DOCX 入库超时: ${JSON.stringify({ sourcePath, lastIngest, lastStatus }, null, 2).slice(0, 4000)}`);
|
||||
}
|
||||
|
||||
async function queryDocx(context, marker, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: marker,
|
||||
mode: "mix",
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
includeChunkContent: true,
|
||||
});
|
||||
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
const references = Array.isArray(result.payload?.references) ? result.payload.references : [];
|
||||
const reference = references.find((item) => item.sourceRootRelativePath === sourcePath);
|
||||
assert(reference, `DOCX 查询缺少目标引用 ${sourcePath}: ${JSON.stringify(references, null, 2).slice(0, 3000)}`);
|
||||
assert(String(reference.quote || reference.content || result.text).toLowerCase().includes(marker.toLowerCase()), `DOCX 引用缺少 marker: ${JSON.stringify(reference, null, 2).slice(0, 2000)}`);
|
||||
assert(String(reference.citationUrl || "").includes("resourceTab="), `DOCX 引用缺少 resourceTab citationUrl: ${JSON.stringify(reference, null, 2)}`);
|
||||
assert(String(reference.citationMarkdown || "").includes(path.basename(sourcePath)), `DOCX 引用缺少 citationMarkdown: ${JSON.stringify(reference, null, 2)}`);
|
||||
return { payload: result.payload, reference };
|
||||
}
|
||||
|
||||
async function openCitationInBrowser(context, reference, sourcePath) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${BASE_URL}${reference.citationUrl}`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expectedResource) => document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`),
|
||||
sourcePath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForTimeout(2_000);
|
||||
const state = await page.evaluate((expectedResource) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`);
|
||||
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
|
||||
return {
|
||||
url: location.href,
|
||||
panelVisible: !!panel && !panel.hidden,
|
||||
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
|
||||
panelKind: panel ? panel.getAttribute("data-resource-kind") : "",
|
||||
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 160) : "",
|
||||
bodyText: document.body.innerText.slice(0, 1000),
|
||||
};
|
||||
}, sourcePath);
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
await page.close().catch(() => undefined);
|
||||
assert.equal(state.panelVisible, true, `DOCX citationUrl 未打开资源 tab: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.panelResourcePath, sourcePath, `DOCX resource tab path 不匹配: ${JSON.stringify(state, null, 2)}`);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const marker = process.env.MNOTE_KNOWLEDGE_RAG_DOCX_MARKER || `DOCX RAG SMOKE ${Date.now()}`;
|
||||
const fileName = `docx-rag-smoke-${Date.now()}.docx`;
|
||||
const sourcePath = `${FIXTURE_DIR}/${fileName}`;
|
||||
const targetPath = path.join(ROOT_PATH, sourcePath);
|
||||
await writeDocxFixture(targetPath, 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 } });
|
||||
try {
|
||||
await signIn(context);
|
||||
const ingestPayload = await ingest(context, sourcePath);
|
||||
const indexed = await waitForIndexed(context, sourcePath);
|
||||
const queried = await queryDocx(context, marker, sourcePath);
|
||||
const browserState = await openCitationInBrowser(context, queried.reference, sourcePath);
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
marker,
|
||||
lightRagDocId: indexed.entry.lightRagDocId,
|
||||
lightRagFilePath: indexed.entry.lightRagFilePath,
|
||||
ingestStatus: ingestPayload?.status,
|
||||
attempts: indexed.attempts,
|
||||
citationMarkdown: queried.reference.citationMarkdown,
|
||||
citationUrl: queried.reference.citationUrl,
|
||||
locatorDegraded: queried.reference.locatorDegraded,
|
||||
browserState,
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/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 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", "task533-knowledge-rag-source-watcher-sync-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_WATCHER_TIMEOUT_MS || 180_000);
|
||||
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 sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function registryPath() {
|
||||
return path.join(ROOT_PATH, ".mnote", "index", "lightrag-source-registry.json");
|
||||
}
|
||||
|
||||
function readRegistry() {
|
||||
return JSON.parse(fs.readFileSync(registryPath(), "utf8"));
|
||||
}
|
||||
|
||||
function findEntry(sourcePath) {
|
||||
const registry = readRegistry();
|
||||
const entries = Array.isArray(registry.entries) ? registry.entries : [];
|
||||
return entries.find((entry) => entry.sourceRootRelativePath === sourcePath) || null;
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.request.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
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, `knowledge-rag status 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sources: [{ sourcePath }],
|
||||
});
|
||||
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function waitForIndexed(context, sourcePath) {
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
attempts += 1;
|
||||
await status(context);
|
||||
const entry = findEntry(sourcePath);
|
||||
if (entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs) {
|
||||
return { entry, attempts };
|
||||
}
|
||||
if (!entry || attempts % 6 === 1) {
|
||||
await ingest(context, sourcePath);
|
||||
}
|
||||
await sleep(5_000);
|
||||
}
|
||||
throw new Error(`等待 watcher source 入库超时: ${sourcePath}`);
|
||||
}
|
||||
|
||||
async function openTreeLiveWatcher(page) {
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
return page.evaluate(({ rootUri }) => new Promise((resolve, reject) => {
|
||||
const params = new URLSearchParams({ rootUri, treeLive: "true" });
|
||||
const source = new EventSource(`/api/local-folder/events?${params.toString()}`);
|
||||
window.__task533Events = [];
|
||||
window.__task533Source = source;
|
||||
const timer = setTimeout(() => reject(new Error("treeLive watcher ready timeout")), 30_000);
|
||||
source.addEventListener("snapshot", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
source.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("treeLive watcher error"));
|
||||
};
|
||||
}), { rootUri: ROOT_URI });
|
||||
}
|
||||
|
||||
async function waitForWatchBatch(page, relativePath) {
|
||||
return page.evaluate(({ expectedPath, timeoutMs }) => new Promise((resolve, reject) => {
|
||||
const source = window.__task533Source;
|
||||
if (!source) {
|
||||
reject(new Error("treeLive watcher not opened"));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => reject(new Error("watch_batch timeout")), timeoutMs);
|
||||
source.addEventListener("watch_batch", (event) => {
|
||||
const payload = JSON.parse(event.data || "{}");
|
||||
window.__task533Events.push(payload);
|
||||
const changed = Array.isArray(payload.changedPaths) ? payload.changedPaths : [];
|
||||
if (changed.some((item) => item.relativePath === expectedPath)) {
|
||||
clearTimeout(timer);
|
||||
resolve(payload);
|
||||
}
|
||||
});
|
||||
}), { expectedPath: relativePath, timeoutMs: POLL_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForRegistryStale(sourcePath) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const entry = findEntry(sourcePath);
|
||||
if (entry && entry.stale === true && entry.deletedAtMs && !entry.lightRagDocId && !entry.indexedAtMs) {
|
||||
return entry;
|
||||
}
|
||||
await sleep(1_000);
|
||||
}
|
||||
throw new Error(`watcher 未把 source 标记 stale/deleted: ${JSON.stringify(findEntry(sourcePath), null, 2)}`);
|
||||
}
|
||||
|
||||
async function queryAfterDelete(context, marker, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: marker,
|
||||
mode: "mix",
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
includeChunkContent: true,
|
||||
});
|
||||
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
const references = Array.isArray(result.payload?.references) ? result.payload.references : [];
|
||||
assert(
|
||||
!references.some((item) => item.sourceRootRelativePath === sourcePath),
|
||||
`删除后的 source 不应继续作为有效 reference: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
|
||||
);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
|
||||
const marker = `WATCHER RAG STALE ${Date.now()}`;
|
||||
const sourcePath = `${FIXTURE_DIR}/watcher-stale-${Date.now()}.md`;
|
||||
const absoluteSource = path.join(ROOT_PATH, sourcePath);
|
||||
fs.writeFileSync(absoluteSource, `# Watcher stale\n${marker}\n`, "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 } });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await signIn(context);
|
||||
await ingest(context, sourcePath);
|
||||
const indexed = await waitForIndexed(context, sourcePath);
|
||||
await openTreeLiveWatcher(page);
|
||||
fs.unlinkSync(absoluteSource);
|
||||
const watchBatch = await waitForWatchBatch(page, sourcePath);
|
||||
const staleEntry = await waitForRegistryStale(sourcePath);
|
||||
const queryPayload = await queryAfterDelete(context, marker, sourcePath);
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
marker,
|
||||
initialDocId: indexed.entry.lightRagDocId,
|
||||
indexedAttempts: indexed.attempts,
|
||||
watchBatch,
|
||||
staleEntry,
|
||||
queryReferenceCount: Array.isArray(queryPayload.references) ? queryPayload.references.length : 0,
|
||||
};
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/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 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", "task534-knowledge-rag-source-management-scope-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "source-management.png");
|
||||
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);
|
||||
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 sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.request.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
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, `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 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 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 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 openKnowledgePanel(page) {
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", ROOT_URI);
|
||||
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 });
|
||||
await page.locator('[data-testid="mnote-knowledge-rag-settings-popover"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSourceRow(page, sourcePath) {
|
||||
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
|
||||
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
|
||||
const marker = `SOURCE SCOPE RAG ${Date.now()}`;
|
||||
const sourceA = `${FIXTURE_DIR}/scope-alpha-${Date.now()}.md`;
|
||||
const sourceB = `${FIXTURE_DIR}/scope-beta-${Date.now()}.md`;
|
||||
fs.writeFileSync(path.join(ROOT_PATH, sourceA), `# Scope Alpha\n${marker}\nOnly alpha source should remain after scope filter.\n`, "utf8");
|
||||
fs.writeFileSync(path.join(ROOT_PATH, sourceB), `# Scope Beta\n${marker}\nBeta source must be filtered when sourcePaths targets alpha.\n`, "utf8");
|
||||
|
||||
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);
|
||||
await ingest(context, [sourceA, sourceB]);
|
||||
const indexedStatus = await waitForIndexed(context, [sourceA, sourceB]);
|
||||
|
||||
const scoped = await query(context, marker, [sourceA]);
|
||||
const scopedReferences = Array.isArray(scoped.references) ? scoped.references : [];
|
||||
assert(scopedReferences.length > 0, `sourcePaths scope 应至少返回 alpha: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
|
||||
assert(
|
||||
scopedReferences.every((reference) => reference.sourceRootRelativePath === sourceA),
|
||||
`sourcePaths scope 不应返回非 alpha 来源: ${JSON.stringify(scopedReferences, null, 2).slice(0, 3000)}`,
|
||||
);
|
||||
|
||||
await openKnowledgePanel(page);
|
||||
const row = await waitForSourceRow(page, sourceA);
|
||||
await row.locator('[data-knowledge-rag-action="reindex-source"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const deleteButton = row.locator('[data-knowledge-rag-action="delete-source"]');
|
||||
await deleteButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await deleteButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
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("已删除");
|
||||
},
|
||||
sourceA,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
assert(fs.existsSync(path.join(ROOT_PATH, sourceA)), "UI 删除索引不应删除原始 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,
|
||||
sourceScope: scoped.sourceScope,
|
||||
scopedReferenceCount: scopedReferences.length,
|
||||
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
|
||||
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
|
||||
sourceAExistsAfterDelete: fs.existsSync(path.join(ROOT_PATH, sourceA)),
|
||||
afterDeleteReferenceCount: afterReferences.length,
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user