Files
mnote/scripts/task175-rust-upload-entry-smoke.js
T
lix-2026 cdff672aa5 feat: align local-first workspace direction
Document the VSCode-like local-first product shape, demote Convex to a control-plane role, and retire stale architecture drafts.

Add local workspace migration/export references plus smoke coverage for no-Convex managed workspace startup, local markdown title/body/options persistence, asset upload behavior, and Convex fixture export.

Verification: git diff --cached --check; node scripts/check-local-first-convex-guard.js --staged; node scripts/task444-convex-workspace-export-local-fixture-smoke.js; node scripts/task166-local-first-managed-workspace-no-convex-smoke.js; node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
2026-05-19 08:11:58 +08:00

369 lines
15 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 fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const PNG_MIME = "image/png";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
// 说明:该 smoke 仍在验证 Convex media / OnlyOffice 的 cloud upload 兼容入口,不是 local-first 默认上传路径。
const PROBE_DOCX_PATH =
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const SCREENSHOT_DIR =
process.env.MNOTE_UPLOAD_ENTRY_SCREENSHOT_DIR ||
"/mnt/Data1T/mnote/tmp/wolai-editor-parity/task175-rust-upload-entry";
function ensureProbeDocx() {
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少 Office 探测文件:${PROBE_DOCX_PATH}`);
return {
name: "task175-slash-attachment.docx",
mimeType: DOCX_MIME,
buffer: fs.readFileSync(PROBE_DOCX_PATH),
};
}
function tinyPngBuffer() {
return Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
"base64",
);
}
function parseSetCookie(setCookie, origin) {
const [nameValue] = String(setCookie || "").split(";");
const separator = nameValue.indexOf("=");
if (separator <= 0) return null;
return {
name: nameValue.slice(0, separator).trim(),
value: nameValue.slice(separator + 1).trim(),
domain: new URL(origin).hostname,
path: "/",
httpOnly: /;\s*httponly\b/i.test(setCookie),
sameSite: "Lax",
};
}
async function forceTestAccountLogin(context) {
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: TEST_EMAIL,
password: TEST_PASSWORD,
flow: "signIn",
},
},
},
headers: { "content-type": "application/json" },
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`);
const cookies = response
.headersArray()
.filter((header) => header.name.toLowerCase() === "set-cookie")
.map((header) => parseSetCookie(header.value, BASE_URL))
.filter(Boolean);
assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
await context.addCookies(cookies);
}
async function waitForRuntimeEditor(page) {
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return editor;
}
async function waitForUploadResponse(page, fileName, action) {
const response = await page.waitForResponse(
async (candidate) => {
if (!candidate.url().includes("/api/media/upload") || candidate.request().method() !== "POST") {
return false;
}
const payload = await candidate.json().catch(() => null);
return Boolean(payload?.asset?.id && (!fileName || payload.asset.file_name === fileName));
},
{ timeout: UI_TIMEOUT_MS },
);
const payload = await response.json();
assert(response.ok(), `${action} 上传失败:${response.status()} ${JSON.stringify(payload)}`);
assert(payload?.asset?.id, `${action} 上传响应缺少 asset.id${JSON.stringify(payload)}`);
return payload.asset;
}
async function waitForAssetRow(page, assetId, action) {
try {
await page.waitForFunction(
(targetAssetId) =>
Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).some(
(row) =>
row instanceof HTMLElement &&
window.getComputedStyle(row).display !== "none" &&
window.getComputedStyle(row).visibility !== "hidden" &&
row.getClientRects().length > 0,
),
assetId,
{ timeout: UI_TIMEOUT_MS },
);
} catch (error) {
const debug = await page.evaluate((targetAssetId) => ({
mode: document.documentElement.getAttribute("data-mnote-sidebar-tree-mode"),
lastUpload: document.documentElement.getAttribute("data-mnote-last-upload-asset-id"),
targetCount: document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`).length,
assets: Array.from(document.querySelectorAll('[data-testid="filetree-asset-row"]')).slice(-12).map((row) => ({
id: row.getAttribute("data-asset-id"),
visible: row instanceof HTMLElement && window.getComputedStyle(row).display !== "none" && window.getComputedStyle(row).visibility !== "hidden" && row.getClientRects().length > 0,
text: row.textContent,
})),
body: document.body.innerText.slice(0, 1200),
}), assetId).catch((debugError) => ({ debugError: String(debugError) }));
throw new Error(`${action} 上传后的文件树附件行不可见:${assetId} ${JSON.stringify(debug)}`);
}
const title = await page.evaluate((targetAssetId) => {
const row = Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).find(
(candidate) =>
candidate instanceof HTMLElement &&
window.getComputedStyle(candidate).display !== "none" &&
window.getComputedStyle(candidate).visibility !== "hidden" &&
candidate.getClientRects().length > 0,
);
return row?.querySelector(".tree-link-title")?.textContent?.trim() || "";
}, assetId);
assert(title.length > 0, `${action} 上传后的文件树附件行缺少标题`);
}
async function waitForEditorOfficeAttachment(page, assetId, fileName, action) {
const attachment = page
.locator(`.editor-surface .ProseMirror a[data-mnote-attachment-link="true"][href*="/onlyoffice"][href*="${assetId}"]`)
.first();
await attachment.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const text = (await attachment.innerText()).trim();
assert(text.includes(fileName), `${action} 正文附件标题不正确:${text}`);
const href = await attachment.getAttribute("href");
assert(href && href.includes("/onlyoffice?"), `${action} 正文 Office 附件 href 应指向 /onlyoffice,实际:${href}`);
assert(
href && href.startsWith("/onlyoffice?"),
`${action} 正文 Office 附件 href 应保存为相对 /onlyoffice 链接,实际:${href}`,
);
assert(href.includes(`assetId=${encodeURIComponent(assetId)}`), `${action} 正文 Office 附件 href 缺少 assetId${href}`);
return attachment;
}
async function assertAttachmentActions(page, attachment, action) {
await attachment.hover({ timeout: UI_TIMEOUT_MS });
const actions = page.locator('[data-testid="mnote-attachment-actions"]').first();
await actions.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await actions.locator('[data-testid="mnote-attachment-action-menu"]').click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const text = await menu.innerText();
for (const label of ["拷贝副本", "删除", "复制链接", "弹窗预览", "右侧预览", "下载", "更换文件", "重命名", "添加说明文字"]) {
assert(text.includes(label), `${action} 附件三点菜单缺少“${label}”:${text}`);
}
await page.keyboard.press("Escape").catch(() => undefined);
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(SCREENSHOT_DIR, `${name}.png`),
fullPage: false,
timeout: UI_TIMEOUT_MS,
});
}
async function uploadViaSlash(page, itemTestId, filePayload, action) {
const editor = await waitForRuntimeEditor(page);
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
const slash = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
await slash.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const slashText = await slash.innerText();
assert(slashText.includes("媒体与附件"), `slash 菜单缺少媒体与附件分组:${slashText}`);
const item = page.locator(`[data-testid="${itemTestId}"]`).first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await item.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
await screenshot(page, `slash-${itemTestId}`);
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
const uploadResponse = waitForUploadResponse(page, filePayload.name, action);
await fileChooser.setFiles(filePayload);
const asset = await uploadResponse;
await waitForAssetRow(page, asset.id, action);
return asset;
}
async function dispatchFileDrop(page, selector, filePayload, action) {
const uploadPromise = waitForUploadResponse(page, filePayload.name, action);
await page.evaluate(
({ selector, fileName, mimeType, bytes }) => {
const target = Array.from(document.querySelectorAll(selector)).find(
(candidate) => candidate instanceof HTMLElement && candidate.getClientRects().length > 0,
) || document.querySelector(selector);
if (!(target instanceof HTMLElement)) {
throw new Error(`拖放目标不存在:${selector}`);
}
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
},
{
selector,
fileName: filePayload.name,
mimeType: filePayload.mimeType,
bytes: Array.from(filePayload.buffer),
},
);
const asset = await uploadPromise;
await waitForAssetRow(page, asset.id, action);
return asset;
}
async function main() {
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const networkNotes = [];
page.on("response", async (response) => {
if (!/\/api\/(media\/upload|tree\/filetree\/upload-target-preflight)/.test(response.url())) return;
const text = await response.text().catch(() => "");
networkNotes.push({
status: response.status(),
url: response.url(),
body: text.slice(0, 1000),
});
});
page.on("pageerror", (error) => {
networkNotes.push({ type: "pageerror", message: error.message });
});
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
networkNotes.push({ type: message.type(), message: message.text().slice(0, 1000) });
}
});
page.on("dialog", async (dialog) => {
networkNotes.push({ type: "dialog", message: dialog.message() });
await dialog.dismiss().catch(() => undefined);
});
let target = null;
try {
await forceTestAccountLogin(context);
await ensureAuthenticated(page, context.request);
target = await createTempDocument(context.request, null);
await openDocument(page, target.workspaceId, target.documentId);
await openFilesystemView(page);
const slashAttachment = ensureProbeDocx();
const slashImage = {
name: "task175-slash-image.png",
mimeType: PNG_MIME,
buffer: tinyPngBuffer(),
};
const treeDropAttachment = {
name: "task175-filetree-drop.docx",
mimeType: DOCX_MIME,
buffer: slashAttachment.buffer,
};
const editorDropImage = {
name: "task175-editor-drop.png",
mimeType: PNG_MIME,
buffer: tinyPngBuffer(),
};
const attachmentAsset = await uploadViaSlash(
page,
"slash-item-upload-attachment",
slashAttachment,
"slash 上传附件",
);
const editorAttachment = await waitForEditorOfficeAttachment(
page,
attachmentAsset.id,
slashAttachment.name,
"slash 上传附件",
);
await assertAttachmentActions(page, editorAttachment, "slash 上传附件");
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForRuntimeEditor(page);
await waitForEditorOfficeAttachment(page, attachmentAsset.id, slashAttachment.name, "刷新后正文附件");
await openFilesystemView(page);
const imageAsset = await uploadViaSlash(page, "slash-item-image", slashImage, "slash 上传图片");
const docRowSelector = `[data-testid="filetree-doc-row"][data-document-id="${target.documentId}"]`;
const droppedAttachment = await dispatchFileDrop(page, docRowSelector, treeDropAttachment, "文件树拖入附件");
const editorStageSelector = '[data-testid="mnote-leptos-tiptap-editor-stage"]';
const droppedImage = await dispatchFileDrop(page, editorStageSelector, editorDropImage, "主编辑区拖入图片");
await page.waitForFunction(
(fileName) => {
const images = Array.from(document.querySelectorAll(".editor-surface .ProseMirror img[src]"));
return images.some((image) => image.getAttribute("alt") === fileName || image.getAttribute("title") === fileName);
},
editorDropImage.name,
{ timeout: UI_TIMEOUT_MS },
);
console.log(
JSON.stringify(
{
ok: true,
workspaceId: target.workspaceId,
documentId: target.documentId,
uploadedAssetIds: [
attachmentAsset.id,
imageAsset.id,
droppedAttachment.id,
droppedImage.id,
],
screenshotDir: SCREENSHOT_DIR,
},
null,
2,
),
);
} catch (error) {
if (networkNotes.length) {
console.error(`上传入口调试信息:${JSON.stringify(networkNotes.slice(-20), null, 2)}`);
}
throw error;
} finally {
if (target?.documentId) {
await purgeDocument(context.request, target.documentId).catch((error) => {
console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`);
});
}
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});