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 = "task428-filetree-bulk-delete-selection-smoke";
|
2026-05-20 11:32:07 +08:00
|
|
|
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
2026-05-16 07:38:45 +08:00
|
|
|
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 批量删除兼容路径,不代表 local-first 默认 File Tree 主链。
|
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 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 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)}"]`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cssEscape(value) {
|
|
|
|
|
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 selectedRows(page) {
|
|
|
|
|
return page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]').evaluateAll((rows) =>
|
|
|
|
|
rows.map((row) => ({
|
|
|
|
|
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
|
|
|
|
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
|
|
|
|
|
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
|
|
|
|
text: row.textContent || "",
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function clickRow(page, selector, options = {}) {
|
|
|
|
|
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS, ...options });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rightClickRow(page, selector) {
|
|
|
|
|
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function closeContextMenu(page) {
|
|
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
|
|
await page.waitForTimeout(150);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteSelectionAndCaptureConfirm(page, key, focusSelector) {
|
|
|
|
|
const target = focusSelector ? page.locator(focusSelector).first() : null;
|
|
|
|
|
if (focusSelector) {
|
|
|
|
|
await target.focus({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
|
|
|
|
const message = dialog.message();
|
|
|
|
|
await dialog.accept();
|
|
|
|
|
return message;
|
|
|
|
|
});
|
|
|
|
|
if (target) {
|
|
|
|
|
await target.press(key, { timeout: UI_TIMEOUT_MS });
|
|
|
|
|
} else {
|
|
|
|
|
await page.keyboard.press(key);
|
|
|
|
|
}
|
|
|
|
|
return await dialogPromise.catch((error) => {
|
|
|
|
|
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteSelectionAndCaptureConfirmAndAlert(page, key, focusSelector) {
|
|
|
|
|
const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
|
|
|
|
const message = dialog.message();
|
|
|
|
|
await dialog.accept();
|
|
|
|
|
return message;
|
|
|
|
|
});
|
|
|
|
|
await page.locator(focusSelector).first().press(key, { timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const confirm = await confirmPromise.catch((error) => {
|
|
|
|
|
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
|
});
|
|
|
|
|
const alert = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
|
|
|
|
const message = dialog.message();
|
|
|
|
|
await dialog.accept();
|
|
|
|
|
return message;
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
throw new Error(`${key} 未触发失败摘要弹窗: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
|
});
|
|
|
|
|
return { confirm, alert };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function expectDetached(page, selector, label) {
|
|
|
|
|
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
|
|
|
|
const state = await selectedRows(page).catch(() => []);
|
|
|
|
|
throw new Error(`${label} 未从 filetree 消失: ${error.message}; selection=${JSON.stringify(state)}`);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 seedFixture(context, request, stamp) {
|
|
|
|
|
const currentUser = await convexCall(context, "query", "users:currentUser", {});
|
|
|
|
|
const userId = currentUser?._id;
|
|
|
|
|
assert(userId, "缺少 Convex currentUser._id");
|
|
|
|
|
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
|
|
|
|
|
const workspaceId = workspaces.activeWorkspaceId;
|
|
|
|
|
assert(workspaceId, "缺少隔离 workspaceId");
|
|
|
|
|
|
|
|
|
|
const rootId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-root-${stamp}`);
|
|
|
|
|
const childId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-child-${stamp}`, rootId);
|
|
|
|
|
const siblingId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-sibling-${stamp}`);
|
|
|
|
|
const failureDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-failure-doc-${stamp}`);
|
|
|
|
|
const resourceDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-resources-${stamp}`);
|
|
|
|
|
|
|
|
|
|
const fileAssetA = `asset_p4_a_${stamp}`;
|
|
|
|
|
const fileAssetB = `asset_p4_b_${stamp}`;
|
|
|
|
|
const failureAssetId = `asset_p4_failure_${stamp}`;
|
|
|
|
|
const mindmapId = `mind_p4_${stamp}`;
|
|
|
|
|
|
|
|
|
|
for (const [assetId, fileName] of [
|
|
|
|
|
[fileAssetA, `TEST-10REVIEW-07-P4-file-a-${stamp}.txt`],
|
|
|
|
|
[fileAssetB, `TEST-10REVIEW-07-P4-file-b-${stamp}.txt`],
|
|
|
|
|
[failureAssetId, `TEST-10REVIEW-07-P4-failure-file-${stamp}.txt`],
|
|
|
|
|
]) {
|
|
|
|
|
await convexCall(context, "mutation", "mediaAssets:create", {
|
|
|
|
|
userId,
|
|
|
|
|
asset: {
|
|
|
|
|
id: assetId,
|
|
|
|
|
workspace_id: workspaceId,
|
|
|
|
|
document_id: resourceDocId,
|
|
|
|
|
asset_type: "file",
|
|
|
|
|
file_url: null,
|
|
|
|
|
thumbnail_url: null,
|
|
|
|
|
storage_id: null,
|
|
|
|
|
bucket: null,
|
|
|
|
|
storage_path: null,
|
|
|
|
|
file_name: fileName,
|
|
|
|
|
file_size: 12,
|
|
|
|
|
mime_type: "text/plain",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await convexCall(context, "mutation", "mindmaps:put", {
|
|
|
|
|
docId: resourceDocId,
|
|
|
|
|
mindmapId,
|
|
|
|
|
data: { root: { data: { text: `TEST-10REVIEW-07-P4-mind-${stamp}` }, children: [] } },
|
|
|
|
|
createOnly: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const table = await convexCall(context, "mutation", "tables:create", {
|
|
|
|
|
userId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId: resourceDocId,
|
|
|
|
|
title: `TEST-10REVIEW-07-P4-table-${stamp}`,
|
|
|
|
|
schema: {},
|
|
|
|
|
snapshot: null,
|
|
|
|
|
});
|
|
|
|
|
await convexCall(context, "mutation", "tables:update", {
|
|
|
|
|
userId,
|
|
|
|
|
tableId: table.id,
|
|
|
|
|
rows: [{ cells: ["p4-row"] }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
userId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
rootId,
|
|
|
|
|
childId,
|
|
|
|
|
siblingId,
|
|
|
|
|
failureDocId,
|
|
|
|
|
resourceDocId,
|
|
|
|
|
fileAssetA,
|
|
|
|
|
fileAssetB,
|
|
|
|
|
failureAssetId,
|
|
|
|
|
mindmapId,
|
|
|
|
|
tableId: table.id,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function assertTrashState(context, fixture, stage) {
|
|
|
|
|
const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", {
|
|
|
|
|
workspaceId: fixture.workspaceId,
|
|
|
|
|
});
|
|
|
|
|
for (const documentId of stage.documentIds || []) {
|
|
|
|
|
assert(trashedDocs.some((doc) => doc.id === documentId), `${stage.name}: 页面 ${documentId} 未进入垃圾箱`);
|
|
|
|
|
}
|
|
|
|
|
for (const assetId of stage.fileAssetIds || []) {
|
|
|
|
|
const row = await convexCall(context, "query", "mediaAssets:getById", { userId: fixture.userId, id: assetId });
|
|
|
|
|
assert(row?.deleted_at, `${stage.name}: 附件 ${assetId} 未进入垃圾箱`);
|
|
|
|
|
}
|
|
|
|
|
if (stage.mindmapId) {
|
|
|
|
|
const mindmaps = await convexCall(context, "query", "mindmaps:listByWorkspace", {
|
|
|
|
|
workspaceId: fixture.workspaceId,
|
|
|
|
|
includeDeleted: true,
|
|
|
|
|
});
|
|
|
|
|
assert(
|
|
|
|
|
mindmaps.some((row) => row.mindmap_id === stage.mindmapId && row.deleted_at),
|
|
|
|
|
`${stage.name}: mindmap ${stage.mindmapId} 未进入垃圾箱`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (stage.tableId) {
|
|
|
|
|
const tables = await convexCall(context, "query", "tables:listByWorkspaceForSearch", {
|
|
|
|
|
userId: fixture.userId,
|
|
|
|
|
workspaceId: fixture.workspaceId,
|
|
|
|
|
includeArchived: true,
|
|
|
|
|
limit: 100,
|
|
|
|
|
});
|
|
|
|
|
assert(tables.some((row) => row.id === stage.tableId && row.deleted_at), `${stage.name}: table ${stage.tableId} 未进入垃圾箱`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cleanup(context, request, fixture) {
|
|
|
|
|
if (!fixture?.workspaceId) return;
|
|
|
|
|
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
|
|
|
|
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
|
|
|
|
await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
|
|
|
|
await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
|
|
|
|
if (fixture.resourceDocId) {
|
|
|
|
|
if (fixture.failureAssetId) {
|
|
|
|
|
await requestJson(request, "/api/media/batch", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
data: { action: "delete", assetIds: [fixture.failureAssetId] },
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
|
|
|
|
}
|
|
|
|
|
await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: fixture.resourceDocId } }).catch(() => null);
|
|
|
|
|
}
|
|
|
|
|
await convexCall(context, "query", "users:currentUser", {}).catch(() => null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
const stamp = Date.now();
|
|
|
|
|
const result = {
|
|
|
|
|
ok: false,
|
|
|
|
|
task: TASK,
|
|
|
|
|
baseUrl: BASE_URL,
|
|
|
|
|
convexUrl: CONVEX_URL,
|
|
|
|
|
email: `mnote.filetree.${stamp}@example.com`,
|
|
|
|
|
requests: [],
|
|
|
|
|
confirms: [],
|
|
|
|
|
fixture: null,
|
|
|
|
|
};
|
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
|
|
const context = await browser.newContext();
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
const request = context.request;
|
|
|
|
|
let fixture = null;
|
|
|
|
|
|
|
|
|
|
page.on("request", (req) => {
|
|
|
|
|
const url = req.url();
|
|
|
|
|
if (
|
|
|
|
|
url.includes("/api/tree/filetree/delete-preflight") ||
|
|
|
|
|
url.includes("/api/tree/commands") ||
|
|
|
|
|
url.includes("/api/media/batch") ||
|
|
|
|
|
url.includes("/api/mindmap/") ||
|
|
|
|
|
url.includes("/api/tables/")
|
|
|
|
|
) {
|
|
|
|
|
result.requests.push({
|
|
|
|
|
method: req.method(),
|
|
|
|
|
url,
|
|
|
|
|
body: req.postData() || null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await requestJson(request, "/api/auth", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
data: {
|
|
|
|
|
action: "auth:signIn",
|
|
|
|
|
args: {
|
|
|
|
|
provider: "password",
|
|
|
|
|
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `filetree-${stamp}` },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
fixture = await seedFixture(context, request, stamp);
|
|
|
|
|
result.fixture = fixture;
|
|
|
|
|
|
|
|
|
|
await openDocumentFileTree(page, fixture.workspaceId, fixture.resourceDocId);
|
|
|
|
|
for (const documentId of [fixture.rootId, fixture.siblingId, fixture.failureDocId, fixture.resourceDocId]) {
|
|
|
|
|
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
|
|
|
|
|
}
|
|
|
|
|
await expandDocIfNeeded(page, fixture.rootId);
|
|
|
|
|
await waitForRow(page, docRowSelector(fixture.childId), `子页面 ${fixture.childId}`);
|
|
|
|
|
await expandDocIfNeeded(page, fixture.resourceDocId);
|
|
|
|
|
for (const assetId of [fixture.fileAssetA, fixture.fileAssetB, fixture.failureAssetId, fixture.mindmapId, fixture.tableId]) {
|
|
|
|
|
await waitForRow(page, assetRowSelector(assetId), `资源 ${assetId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const accel = process.platform === "darwin" ? "Meta" : "Control";
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.rootId));
|
|
|
|
|
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
|
|
|
|
|
let selected = await selectedRows(page);
|
|
|
|
|
assert(selected.length >= 2, `Ctrl/Cmd 多选后选区数量异常: ${JSON.stringify(selected)}`);
|
|
|
|
|
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.rootId));
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.childId), { modifiers: ["Shift"] });
|
|
|
|
|
selected = await selectedRows(page);
|
|
|
|
|
assert(selected.length >= 2, `Shift 范围选择后选区数量异常: ${JSON.stringify(selected)}`);
|
|
|
|
|
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.rootId));
|
|
|
|
|
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
|
|
|
|
|
const beforeContextSelected = await selectedRows(page);
|
|
|
|
|
await rightClickRow(page, assetRowSelector(fixture.fileAssetA));
|
|
|
|
|
const afterContextSelected = await selectedRows(page);
|
|
|
|
|
assert(
|
|
|
|
|
afterContextSelected.length === beforeContextSelected.length,
|
|
|
|
|
`右键已选项不应清空多选: before=${JSON.stringify(beforeContextSelected)} after=${JSON.stringify(afterContextSelected)}`,
|
|
|
|
|
);
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
await rightClickRow(page, assetRowSelector(fixture.fileAssetB));
|
|
|
|
|
selected = await selectedRows(page);
|
|
|
|
|
assert(
|
|
|
|
|
selected.length === 1 && selected[0].assetId === fixture.fileAssetB,
|
|
|
|
|
`右键未选项应切换 action target: ${JSON.stringify(selected)}`,
|
|
|
|
|
);
|
|
|
|
|
await closeContextMenu(page);
|
|
|
|
|
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.rootId));
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.childId), { modifiers: [accel] });
|
|
|
|
|
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
|
|
|
|
|
await clickRow(page, assetRowSelector(fixture.mindmapId), { modifiers: [accel] });
|
|
|
|
|
await clickRow(page, assetRowSelector(fixture.tableId), { modifiers: [accel] });
|
|
|
|
|
selected = await selectedRows(page);
|
|
|
|
|
assert(selected.length >= 5, `Delete 前混合选区数量异常: ${JSON.stringify(selected)}`);
|
|
|
|
|
const deleteConfirm = await deleteSelectionAndCaptureConfirm(page, "Delete", assetRowSelector(fixture.tableId));
|
|
|
|
|
result.confirms.push({ key: "Delete", message: deleteConfirm });
|
|
|
|
|
assert(deleteConfirm.includes("1 个页面"), `Delete 确认文案缺少页面计数: ${deleteConfirm}`);
|
|
|
|
|
assert(deleteConfirm.includes("1 个附件"), `Delete 确认文案缺少附件计数: ${deleteConfirm}`);
|
|
|
|
|
assert(deleteConfirm.includes("1 个思维导图"), `Delete 确认文案缺少思维导图计数: ${deleteConfirm}`);
|
|
|
|
|
assert(deleteConfirm.includes("1 个在线表格"), `Delete 确认文案缺少在线表格计数: ${deleteConfirm}`);
|
|
|
|
|
|
|
|
|
|
await expectDetached(page, docRowSelector(fixture.rootId), "root 页面");
|
|
|
|
|
await expectDetached(page, docRowSelector(fixture.childId), "child 页面");
|
|
|
|
|
await expectDetached(page, assetRowSelector(fixture.fileAssetA), "普通附件 A");
|
|
|
|
|
await expectDetached(page, assetRowSelector(fixture.mindmapId), "mindmap");
|
|
|
|
|
await expectDetached(page, assetRowSelector(fixture.tableId), "table");
|
|
|
|
|
await assertTrashState(context, fixture, {
|
|
|
|
|
name: "Delete",
|
|
|
|
|
documentIds: [fixture.rootId, fixture.childId],
|
|
|
|
|
fileAssetIds: [fixture.fileAssetA],
|
|
|
|
|
mindmapId: fixture.mindmapId,
|
|
|
|
|
tableId: fixture.tableId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.siblingId));
|
|
|
|
|
await clickRow(page, assetRowSelector(fixture.fileAssetB), { modifiers: [accel] });
|
|
|
|
|
selected = await selectedRows(page);
|
|
|
|
|
assert(selected.length >= 2, `Backspace 前混合选区数量异常: ${JSON.stringify(selected)}`);
|
|
|
|
|
const backspaceConfirm = await deleteSelectionAndCaptureConfirm(page, "Backspace", assetRowSelector(fixture.fileAssetB));
|
|
|
|
|
result.confirms.push({ key: "Backspace", message: backspaceConfirm });
|
|
|
|
|
assert(backspaceConfirm.includes("1 个页面"), `Backspace 确认文案缺少页面计数: ${backspaceConfirm}`);
|
|
|
|
|
assert(backspaceConfirm.includes("1 个附件"), `Backspace 确认文案缺少附件计数: ${backspaceConfirm}`);
|
|
|
|
|
await expectDetached(page, docRowSelector(fixture.siblingId), "sibling 页面");
|
|
|
|
|
await expectDetached(page, assetRowSelector(fixture.fileAssetB), "普通附件 B");
|
|
|
|
|
await assertTrashState(context, fixture, {
|
|
|
|
|
name: "Backspace",
|
|
|
|
|
documentIds: [fixture.siblingId],
|
|
|
|
|
fileAssetIds: [fixture.fileAssetB],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await clickRow(page, docRowSelector(fixture.failureDocId));
|
|
|
|
|
await clickRow(page, assetRowSelector(fixture.failureAssetId), { modifiers: [accel] });
|
|
|
|
|
let mediaBatchFailed = false;
|
|
|
|
|
await page.route("**/api/media/batch", async (route) => {
|
|
|
|
|
const postData = route.request().postData() || "";
|
|
|
|
|
if (postData.includes(fixture.failureAssetId)) {
|
|
|
|
|
mediaBatchFailed = true;
|
|
|
|
|
await route.fulfill({
|
|
|
|
|
status: 500,
|
|
|
|
|
contentType: "application/json",
|
|
|
|
|
body: JSON.stringify({ error: "task428 forced media failure" }),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await route.continue();
|
|
|
|
|
});
|
|
|
|
|
const failureDialogs = await deleteSelectionAndCaptureConfirmAndAlert(page, "Delete", assetRowSelector(fixture.failureAssetId));
|
|
|
|
|
await page.unroute("**/api/media/batch").catch(() => undefined);
|
|
|
|
|
result.confirms.push({ key: "Delete-partial", message: failureDialogs.confirm });
|
|
|
|
|
result.partialFailureAlert = failureDialogs.alert;
|
|
|
|
|
assert(mediaBatchFailed, "部分失败场景未拦截到 media batch 请求");
|
|
|
|
|
assert(failureDialogs.alert.includes("部分对象删除失败"), `失败摘要文案不正确: ${failureDialogs.alert}`);
|
|
|
|
|
await expectDetached(page, docRowSelector(fixture.failureDocId), "partial failure 页面");
|
|
|
|
|
await waitForRow(page, assetRowSelector(fixture.failureAssetId), "partial failure 附件应保留");
|
|
|
|
|
await assertTrashState(context, fixture, {
|
|
|
|
|
name: "PartialFailure",
|
|
|
|
|
documentIds: [fixture.failureDocId],
|
|
|
|
|
});
|
|
|
|
|
const failureAsset = await convexCall(context, "query", "mediaAssets:getById", {
|
|
|
|
|
userId: fixture.userId,
|
|
|
|
|
id: fixture.failureAssetId,
|
|
|
|
|
});
|
|
|
|
|
assert(failureAsset && !failureAsset.deleted_at, "部分失败后附件不应被错误移入垃圾箱");
|
|
|
|
|
|
|
|
|
|
result.ok = true;
|
|
|
|
|
await writeResult(result);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
|
|
|
|
result.selectedRows = await selectedRows(page).catch(() => []);
|
|
|
|
|
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
|
|
|
|
|
await writeResult(result);
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
await cleanup(context, request, fixture).catch((error) => {
|
|
|
|
|
console.warn(`清理 task428 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
|
});
|
|
|
|
|
await browser.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main().catch((error) => {
|
|
|
|
|
console.error(error);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|