Files
mnote/scripts/task526-local-folder-ocr-api-smoke.js
T

313 lines
13 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 });
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-testid="mnote-local-ocr-toolbar"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("mnote-local-ocr-run").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => document.querySelector('[data-mnote-local-ocr-status="done"]'), null, {
timeout: UI_TIMEOUT_MS,
});
const uiOcrPath = await page.locator('[data-mnote-local-ocr-path]').first().getAttribute("data-mnote-local-ocr-path");
assert(uiOcrPath && uiOcrPath.endsWith(".ocr.md"), `UI OCR path invalid: ${uiOcrPath}`);
screenshots.ocrToolbar = path.join(OUTPUT_DIR, "02-ocr-toolbar.png");
await page.screenshot({ path: screenshots.ocrToolbar, fullPage: true });
await page.getByTestId("mnote-local-ocr-insert").click({ timeout: UI_TIMEOUT_MS });
await page.getByTestId("mnote-local-ocr-status").filter({ hasText: "OCR 链接已插入正文" }).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("mnote-local-ocr-open").click({ timeout: UI_TIMEOUT_MS });
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,
});
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}`);
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"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
screenshots.ocrResource = path.join(OUTPUT_DIR, "03-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);
});