2026-05-16 07:38:45 +08:00
|
|
|
|
#!/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("");
|
2026-05-19 08:11:58 +08:00
|
|
|
|
// 说明:该 smoke 覆盖 convex-source Explorer 兼容路径;local-first 默认主链另有 local workspace smoke。
|
2026-05-16 07:38:45 +08:00
|
|
|
|
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: [],
|
2026-05-21 11:01:17 +08:00
|
|
|
|
phases: [],
|
2026-05-16 07:38:45 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-21 11:01:17 +08:00
|
|
|
|
// 阶段:auth - signIn
|
|
|
|
|
|
result.phases.push({ name: "auth-signin", ok: false });
|
2026-05-16 07:38:45 +08:00
|
|
|
|
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}` },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-05-21 11:01:17 +08:00
|
|
|
|
result.phases[result.phases.length - 1].ok = true;
|
2026-05-16 07:38:45 +08:00
|
|
|
|
|
2026-05-21 11:01:17 +08:00
|
|
|
|
// 阶段:auth - cookie 初始化(导航到首页以设置 cookie)
|
|
|
|
|
|
result.phases.push({ name: "auth-navigate", ok: false });
|
2026-05-16 07:38:45 +08:00
|
|
|
|
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
2026-05-21 11:01:17 +08:00
|
|
|
|
result.phases[result.phases.length - 1].ok = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 阶段:workspace bootstrap
|
|
|
|
|
|
result.phases.push({ name: "workspace-bootstrap", ok: false });
|
2026-05-16 07:38:45 +08:00
|
|
|
|
const currentUser = await convexCall(context, "query", "users:currentUser", {});
|
|
|
|
|
|
const userId = currentUser?._id;
|
2026-05-21 11:01:17 +08:00
|
|
|
|
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;
|
2026-05-16 07:38:45 +08:00
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
|
2026-05-21 11:01:17 +08:00
|
|
|
|
// 阶段: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 区域尚未实现(当前为已知缺口)" };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|