Files
mnote/scripts/task430-vscode-explorer-stage7-smoke.js
T

598 lines
24 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/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task430-vscode-explorer-stage7-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source Explorer 兼容路径;local-first 默认主链另有 local workspace smoke。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestJsonAllowError(request, requestPath, init = {}) {
let response;
try {
response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
} catch (error) {
return {
ok: false,
status: 0,
payload: {
error: error instanceof Error ? error.message : String(error),
},
};
}
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
return { ok: response.ok(), status: response.status(), payload };
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result.documentId;
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForRow(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`);
});
}
async function expandDocIfNeeded(page, documentId) {
const selector = docRowSelector(documentId);
await waitForRow(page, selector, `页面 ${documentId}`);
const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null);
if (expanded === "true") return;
const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
if (!clicked) {
await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined);
}
}
async function readDocument(context, workspaceId, documentId) {
const docs = await convexCall(context, "query", "documents:listByWorkspace", { workspaceId });
return docs.find((doc) => doc.id === documentId) || null;
}
async function waitForAssetFileName(context, userId, assetId, fileName) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let lastAsset = null;
while (Date.now() < deadline) {
lastAsset = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId });
if (lastAsset?.file_name === fileName) {
return lastAsset;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
assert.equal(lastAsset?.file_name, fileName, "F2 inline rename 附件后 Convex 文件名未更新");
return lastAsset;
}
async function renameRowWithF2(page, selector, nextTitle) {
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS });
await page.locator(selector).first().press("F2", { timeout: UI_TIMEOUT_MS });
const input = page.locator(`${selector} .tree-rename-input`).first();
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await input.fill(nextTitle, { timeout: UI_TIMEOUT_MS });
await input.press("Enter", { timeout: UI_TIMEOUT_MS });
await page.locator(selector).first().getByText(nextTitle, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function visibleContextMenuLabels(page, selector) {
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(250);
const labels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
const visible = [];
for (const label of labels) {
if (await page.getByText(label, { exact: true }).first().isVisible().catch(() => false)) {
visible.push(label);
}
}
const disabledTitles = await page.evaluate(() =>
Array.from(document.querySelectorAll("button[disabled][title]"))
.map((button) => button instanceof HTMLButtonElement ? button.title : "")
.filter(Boolean),
);
await page.keyboard.press("Escape").catch(() => undefined);
return { visible, disabledTitles };
}
async function runDropPreflight(request, payload) {
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
method: "POST",
data: payload,
});
}
function preflightRow({ rowId, rowKind, documentId, assetId = null, assetDocumentId = null, assetType = null, storagePath = null }) {
return { rowId, rowKind, documentId, assetId, assetDocumentId, assetType, storagePath };
}
async function cleanup(request, workspaceId, ids) {
if (!workspaceId) return;
for (const documentId of ids.filter(Boolean)) {
await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "archive", workspaceId, documentId },
}).catch(() => null);
}
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
async function runOptional(result, area, fn) {
try {
const details = await fn();
result.checks.push({ area, ok: true, details });
return details;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result.skipped.push({ area, reason });
result.checks.push({ area, ok: false, skipped: true, reason });
return null;
}
}
async function main() {
const stamp = Date.now();
const prefix = `TEST-10REVIEW-07-P7-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.stage7.${stamp}@example.com`,
prefix,
requests: [],
checks: [],
skipped: [],
phases: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
let workspaceId = null;
const cleanupDocIds = [];
page.on("request", (req) => {
const url = req.url();
if (
url.includes("/api/tree/commands") ||
url.includes("/api/tree/filetree/drop-preflight") ||
url.includes("/api/media/batch")
) {
result.requests.push({
method: req.method(),
url,
body: req.postData() || null,
});
}
});
try {
// 阶段:auth - signIn
result.phases.push({ name: "auth-signin", ok: false });
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-${stamp}` },
},
},
});
result.phases[result.phases.length - 1].ok = true;
// 阶段:auth - cookie 初始化(导航到首页以设置 cookie)
result.phases.push({ name: "auth-navigate", ok: false });
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
result.phases[result.phases.length - 1].ok = true;
// 阶段:workspace bootstrap
result.phases.push({ name: "workspace-bootstrap", ok: false });
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "[env] Convex 不可用: users:currentUser 未返回用户 ID");
let workspaces;
try {
workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
} catch (workspaceQueryError) {
result.skipped.push({
area: "workspace-bootstrap",
reason: `workspaces:fetchWorkspaceSummaries 查询失败(可能是函数未部署或 Convex 不可用),尝试通过 /api/tree/commands create 自举: ${workspaceQueryError instanceof Error ? workspaceQueryError.message : String(workspaceQueryError)}`,
});
}
workspaceId = workspaces?.activeWorkspaceId;
if (!workspaceId) {
// Fallback: 通过 /api/tree/commands create(不带 workspaceId)触发 Rust 端 workspace bootstrap
const bootstrapResult = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", title: `${prefix}-bootstrap` },
});
workspaceId = bootstrapResult.workspaceId || bootstrapResult.result?.workspaceId;
}
assert(workspaceId, "[env] workspace bootstrap 失败:fetchWorkspaceSummaries 无返回 + /api/tree/commands create 自举也未产生 workspaceId");
result.phases[result.phases.length - 1].ok = true;
const parentA = await createPage(request, workspaceId, `${prefix}-parent-a`);
const parentB = await createPage(request, workspaceId, `${prefix}-parent-b`);
const child = await createPage(request, workspaceId, `${prefix}-child`, parentA);
const renameDoc = await createPage(request, workspaceId, `${prefix}-rename-doc`, parentA);
const resourceDoc = await createPage(request, workspaceId, `${prefix}-resource-doc`);
cleanupDocIds.push(parentA, parentB, child, renameDoc, resourceDoc);
const assetId = `asset_p7_${stamp}`;
await convexCall(context, "mutation", "mediaAssets:create", {
userId,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: resourceDoc,
asset_type: "file",
file_url: null,
thumbnail_url: null,
storage_id: null,
bucket: null,
storage_path: null,
file_name: `${prefix}-file.txt`,
file_size: 12,
mime_type: "text/plain",
},
});
await openDocumentFileTree(page, workspaceId, resourceDoc);
for (const documentId of [parentA, parentB, resourceDoc]) {
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
}
await expandDocIfNeeded(page, parentA);
await waitForRow(page, docRowSelector(child), `子页面 ${child}`);
await waitForRow(page, docRowSelector(renameDoc), `重命名页面 ${renameDoc}`);
await expandDocIfNeeded(page, resourceDoc);
await waitForRow(page, assetRowSelector(assetId), `附件 ${assetId}`);
// 阶段:FileTree active row 与 Open Editors 状态
await runOptional(result, "filetree-active-row", async () => {
const activeRowIds = await page.evaluate(() => {
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]');
return Array.from(rows).map(row => row.getAttribute('data-row-id') || '');
});
const expectedDocRow = `doc:${resourceDoc}`;
if (activeRowIds.length === 0) {
// 当前打开的资源行可能没有 data-active 标记——记录但不报错,属于已知缺口
return { activeRowIds, note: `当前页 ${expectedDocRow} 的 FileTree row 无 data-active="true" 标记(UI 尚未同步 active 状态)` };
}
assert(activeRowIds.includes(expectedDocRow), `业务: FileTree active row 应为 ${expectedDocRow},实际为 [${activeRowIds.join(", ")}]`);
return { activeRowIds, expectedDocRow };
});
await runOptional(result, "filetree-selected-row", async () => {
const selectedRowIds = await page.evaluate(() => {
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]');
return Array.from(rows).map(row => row.getAttribute('data-row-id') || '');
});
// 当前只记录选中行状态,不做强制断言——选中语义可能随点击位置变化
return { selectedRowIds };
});
await runOptional(result, "open-editors-section", async () => {
const hasOpenEditors = await page.evaluate(() => {
return !!(document.querySelector('[data-testid="open-editors"]') ||
document.querySelector('[data-mnote-section="open-editors"]') ||
document.getElementById('sidebar-open-editors') ||
document.querySelector('[data-mnote-editors-section]'));
});
// Open Editors section 当前尚未在 MNote sidebar 中实现(已知缺口 vs VSCode Open Editors
// 这里只记录状态不报错,让将来实装时可以切换为断言
return { implemented: hasOpenEditors, note: hasOpenEditors ? "Open Editors 区域已出现" : "Open Editors 区域尚未实现(当前为已知缺口)" };
});
await runOptional(result, "f2-inline-rename-doc", async () => {
const renamedDocTitle = `${prefix}-renamed-doc`;
await renameRowWithF2(page, docRowSelector(renameDoc), renamedDocTitle);
const renamedDoc = await readDocument(context, workspaceId, renameDoc);
assert.equal(renamedDoc?.title, renamedDocTitle, "F2 inline rename 页面后 Convex 标题未更新");
return { documentId: renameDoc, title: renamedDocTitle };
});
await runOptional(result, "f2-inline-rename-asset", async () => {
const renamedAssetTitle = `${prefix}-renamed-file.txt`;
await renameRowWithF2(page, assetRowSelector(assetId), renamedAssetTitle);
await waitForAssetFileName(context, userId, assetId, renamedAssetTitle);
return { assetId, fileName: renamedAssetTitle };
});
const menu = await visibleContextMenuLabels(page, docRowSelector(parentA));
result.contextMenu = menu;
const expectedMenuLabels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
const missingMenuLabels = expectedMenuLabels.filter((label) => !menu.visible.includes(label));
if (missingMenuLabels.length > 0) {
result.skipped.push({
area: "context-menu-minimum",
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
});
result.checks.push({
area: "context-menu-minimum",
ok: false,
skipped: true,
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
});
}
assert(
menu.visible.length === 0 || menu.disabledTitles.some((title) => title.includes("右键 Paste Into") || title.includes("文件夹")),
`右键菜单禁用态缺少可解释原因: ${JSON.stringify(menu)}`,
);
if (missingMenuLabels.length === 0) {
result.checks.push({
area: "context-menu-minimum",
ok: true,
details: menu,
});
}
const accel = process.platform === "darwin" ? "Meta" : "Control";
await runOptional(result, "cut-paste-move", async () => {
await page.locator(docRowSelector(child)).first().click({ timeout: UI_TIMEOUT_MS });
await page.locator(docRowSelector(child)).first().press(`${accel}+X`, { timeout: UI_TIMEOUT_MS });
await page.locator(docRowSelector(parentB)).first().click({ timeout: UI_TIMEOUT_MS });
const moveRequestCount = result.requests.length;
await page.locator(docRowSelector(parentB)).first().press(`${accel}+V`, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ selector, expectedParent }) => {
const row = document.querySelector(selector);
return row instanceof HTMLElement && row.textContent?.includes(expectedParent);
},
{ selector: docRowSelector(parentB), expectedParent: `${prefix}-parent-b` },
{ timeout: UI_TIMEOUT_MS },
).catch(() => undefined);
await page.waitForTimeout(800);
const movedChild = await readDocument(context, workspaceId, child);
assert.equal(movedChild?.parent_id, parentB, "Ctrl/Cmd+X 后 Ctrl/Cmd+V 应移动页面到目标父页面");
const moveRequests = result.requests.slice(moveRequestCount).filter((entry) => entry.body?.includes('"action":"move"'));
assert(moveRequests.length > 0, "Cut/Paste move 未捕获到 tree move 请求");
return { documentId: child, parentId: parentB, moveRequestCount: moveRequests.length };
});
const childAfterCutPaste = await readDocument(context, workspaceId, child);
const childParentId = childAfterCutPaste?.parent_id ?? parentA;
const rows = [
preflightRow({ rowId: `doc:${parentA}`, rowKind: "doc", documentId: parentA }),
preflightRow({ rowId: `doc:${parentB}`, rowKind: "doc", documentId: parentB }),
preflightRow({ rowId: `doc:${child}`, rowKind: "doc", documentId: child }),
preflightRow({ rowId: `doc:${renameDoc}`, rowKind: "doc", documentId: renameDoc }),
preflightRow({
rowId: `asset:${assetId}`,
rowKind: "asset",
documentId: resourceDoc,
assetId,
assetDocumentId: resourceDoc,
assetType: "file",
}),
];
const documentParents = [
{ documentId: parentA, parentId: null },
{ documentId: parentB, parentId: null },
{ documentId: child, parentId: childParentId },
{ documentId: renameDoc, parentId: parentA },
{ documentId: resourceDoc, parentId: null },
];
const selfDrop = await runDropPreflight(request, {
workspaceId,
copy: false,
targetDocumentId: child,
targetRowId: `doc:${child}`,
focusedRowId: `doc:${child}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${child}`],
rows,
documentParents,
});
const parentToChildDrop = await runDropPreflight(request, {
workspaceId,
copy: false,
targetDocumentId: renameDoc,
targetRowId: `doc:${renameDoc}`,
focusedRowId: `doc:${renameDoc}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${parentA}`],
rows,
documentParents,
});
const copyDrop = await runDropPreflight(request, {
workspaceId,
copy: true,
targetDocumentId: parentB,
targetRowId: `doc:${parentB}`,
focusedRowId: `doc:${parentB}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${renameDoc}`],
rows,
documentParents,
});
if ([selfDrop, parentToChildDrop, copyDrop].some((entry) => entry.status === 0 || entry.status === 404)) {
result.skipped.push({
area: "dnd-preflight-guard",
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight,无法稳定自动化 DnD preflight guard;未把入口失败伪造成通过。",
});
result.checks.push({
area: "dnd-preflight-guard",
ok: false,
skipped: true,
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight",
});
} else {
assert(!selfDrop.ok, `拖到自身应被 preflight 拒绝: ${JSON.stringify(selfDrop)}`);
assert(!parentToChildDrop.ok, `父拖子应被 preflight 拒绝: ${JSON.stringify(parentToChildDrop)}`);
assert(copyDrop.ok && copyDrop.payload?.plan?.copy === true, `copy modifier preflight 应保留 copy=true: ${JSON.stringify(copyDrop)}`);
result.checks.push({
area: "dnd-preflight-guard",
ok: true,
details: {
selfDropStatus: selfDrop.status,
parentToChildDropStatus: parentToChildDrop.status,
copyDropPlan: copyDrop.payload?.plan ?? null,
},
});
}
result.dndPreflight = {
selfDropStatus: selfDrop.status,
parentToChildDropStatus: parentToChildDrop.status,
copyDropPlan: copyDrop.payload?.plan ?? null,
};
result.skipped.push({
area: "dnd-readonly-conflict",
reason: "主 Sidebar Convex filetree 当前 smoke 未构造 readonly source 与真实重名冲突确认弹窗;已有 local-folder smoke 和 bridge preflight 单测覆盖,仍需后续端到端矩阵补齐。",
});
result.ok = true;
result.workspaceId = workspaceId;
result.fixture = { parentA, parentB, child, renameDoc, resourceDoc, assetId };
await writeResult(result);
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
await writeResult(result);
throw error;
} finally {
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
console.warn(`清理 task430 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
});
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});