Files
mnote/scripts/task526-local-folder-ocr-api-smoke.js
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
2026-06-01 09:29:12 +08:00

263 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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![photo](./Page.assets/photo.png)\n", "utf8");
fs.writeFileSync(path.join(root, sourceRootRelativePath), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
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);
screenshots.page = path.join(OUTPUT_DIR, "01-page.png");
await page.screenshot({ path: screenshots.page, fullPage: true });
const result = await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId, ocrToken }) => {
async function jsonFetch(pathname, init = {}) {
const response = await fetch(pathname, {
...init,
headers: {
"content-type": "application/json",
...(init.headers || {}),
},
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
const create = await jsonFetch("/api/local-folder/ocr/jobs", {
method: "POST",
body: JSON.stringify({
rootUri,
documentId,
sourceRootRelativePath,
provider: "mock",
mockMarkdown: `# OCR Result\n\n${ocrToken} browser smoke text`,
}),
});
const ocrRootRelativePath = create.payload?.job?.ocrRootRelativePath || "";
const read = await jsonFetch(`/api/local-folder/ocr/read?rootUri=${encodeURIComponent(rootUri)}&ocrRootRelativePath=${encodeURIComponent(ocrRootRelativePath)}`, {
method: "GET",
headers: {},
});
const status = await jsonFetch(`/api/local-folder/ocr/status?rootUri=${encodeURIComponent(rootUri)}&sourceRootRelativePath=${encodeURIComponent(sourceRootRelativePath)}`, {
method: "GET",
headers: {},
});
const jobs = await jsonFetch(`/api/local-folder/ocr/jobs?rootUri=${encodeURIComponent(rootUri)}`, {
method: "GET",
headers: {},
});
const withoutOcr = await jsonFetch("/api/search/documents", {
method: "POST",
body: JSON.stringify({
workspaceId,
sourceKind: "local_folder",
rootUri,
query: ocrToken,
limit: 5,
filters: { includeOcr: false },
}),
});
const withOcr = await jsonFetch("/api/search/documents", {
method: "POST",
body: JSON.stringify({
workspaceId,
sourceKind: "local_folder",
rootUri,
query: ocrToken,
limit: 5,
filters: { includeOcr: true },
}),
});
const insert = await jsonFetch("/api/local-folder/ocr/insert", {
method: "POST",
body: JSON.stringify({
rootUri,
documentId,
ocrRootRelativePath,
mode: "link",
}),
});
return { create, read, status, jobs, withoutOcr, withOcr, insert, ocrRootRelativePath };
}, {
rootUri: fileUrl(root),
documentId,
sourceRootRelativePath,
workspaceId,
ocrToken,
});
assert.equal(result.create.status, 200, `OCR create failed: ${JSON.stringify(result.create)}`);
assert.equal(result.create.payload.job.status, "done", `OCR job should be done: ${JSON.stringify(result.create.payload)}`);
assert(result.ocrRootRelativePath.endsWith(".ocr.md"), `OCR path invalid: ${result.ocrRootRelativePath}`);
assert.equal(result.read.status, 200, `OCR read failed: ${JSON.stringify(result.read)}`);
assert(result.read.payload.markdown.includes(ocrToken), "OCR read should include mock OCR text");
assert.equal(result.status.payload.job.ocrRootRelativePath, result.ocrRootRelativePath, "status should return OCR sidecar path");
assert(result.jobs.payload.jobs.some((job) => job.ocrRootRelativePath === result.ocrRootRelativePath), "jobs list should include OCR job");
assert.equal(result.withoutOcr.payload.results.length, 0, `includeOcr=false should not match OCR text: ${JSON.stringify(result.withoutOcr.payload)}`);
const ocrResult = result.withOcr.payload.results.find((item) => item.hasOcr === true);
assert(ocrResult, `includeOcr=true should return owner page OCR result: ${JSON.stringify(result.withOcr.payload)}`);
assert.equal(ocrResult.documentId, documentId, "OCR search result should point to owner document");
assert.equal(ocrResult.ocrEvidence.ocrRootRelativePath, result.ocrRootRelativePath, "OCR evidence should include sidecar path");
assert.equal(result.insert.status, 200, `OCR insert failed: ${JSON.stringify(result.insert)}`);
const ownerMarkdown = fs.readFileSync(path.join(root, relativePath), "utf8");
assert(ownerMarkdown.includes(`[OCRphoto.png](`), `owner markdown should include explicit OCR link:\n${ownerMarkdown}`);
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
timeout: UI_TIMEOUT_MS,
});
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: result.ocrRootRelativePath,
workspaceId,
});
assert.equal(openResourceResult, true, "OCR sidecar resource tab should open");
await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"] [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
screenshots.ocrResource = path.join(OUTPUT_DIR, "02-ocr-resource-tab.png");
await page.screenshot({ path: screenshots.ocrResource, fullPage: true });
await writeResult({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: result.ocrRootRelativePath,
screenshots,
});
console.log(JSON.stringify({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: result.ocrRootRelativePath,
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);
});