Files
mnote/scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js
T
lix-2026 384da4e44c feat(tree): checkpoint resource lifecycle work
提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
2026-05-16 07:38:45 +08:00

296 lines
9.9 KiB
JavaScript

#!/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 = "task431-vscode-explorer-dnd-readonly-conflict-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).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("");
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 requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_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;
}
async function createPage(request, workspaceId, title, parentId = null) {
const data = {
action: "create",
parentId,
title,
};
if (workspaceId) {
data.workspaceId = workspaceId;
}
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data,
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result;
}
function docRow(documentId, title) {
return {
rowId: `doc:${documentId}`,
rowKind: "doc",
documentId,
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
title,
};
}
async function runDropPreflight(request, payload) {
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
method: "POST",
data: payload,
});
}
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);
}
(async () => {
const stamp = Date.now();
const prefix = `TEST-10REVIEW-07-P7-DND-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.stage7.dnd.${stamp}@example.com`,
prefix,
checks: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const request = context.request;
let workspaceId = null;
const cleanupDocIds = [];
try {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-dnd-${stamp}` },
},
},
});
const currentUser = await convexCall(context, "query", "users:currentUser", {});
assert(currentUser?._id, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
workspaceId = workspaces.activeWorkspaceId;
const targetResult = await createPage(request, workspaceId, `${prefix}-target`);
workspaceId = workspaceId || targetResult.workspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const target = targetResult.documentId;
const source = (await createPage(request, workspaceId, `${prefix}-same-name`)).documentId;
const existing = (await createPage(request, workspaceId, `${prefix}-same-name`, target)).documentId;
cleanupDocIds.push(target, source, existing);
const rows = [
docRow(target, `${prefix}-target`),
docRow(source, `${prefix}-same-name`),
docRow(existing, `${prefix}-same-name`),
];
const documentParents = [
{ documentId: target, parentId: null },
{ documentId: source, parentId: null },
{ documentId: existing, parentId: target },
];
const basePayload = {
workspaceId,
copy: false,
sourceCapabilities: ["read", "write", "move"],
targetCapabilities: ["read", "write", "drop"],
targetDocumentId: target,
targetRowId: `doc:${target}`,
focusedRowId: `doc:${target}`,
activeDocumentId: target,
rowIds: [`doc:${source}`],
rows,
targetChildren: [
{
rowKind: "doc",
documentId: existing,
assetId: null,
title: `${prefix}-same-name`,
},
],
documentParents,
conflictPolicy: "prompt",
};
const readonlyTarget = await runDropPreflight(request, {
...basePayload,
targetCapabilities: ["read"],
targetChildren: [],
});
assert.equal(readonlyTarget.ok, false, `readonly target 应被拒绝: ${JSON.stringify(readonlyTarget)}`);
assert.match(JSON.stringify(readonlyTarget.payload), /只读|readonly|目标位置/);
const readonlySource = await runDropPreflight(request, {
...basePayload,
sourceCapabilities: ["read"],
targetChildren: [],
});
assert.equal(readonlySource.ok, false, `readonly source 应被拒绝: ${JSON.stringify(readonlySource)}`);
assert.match(JSON.stringify(readonlySource.payload), /只读|readonly|来源/);
const conflict = await runDropPreflight(request, basePayload);
assert.equal(conflict.ok, true, `同名冲突应返回确认计划: ${JSON.stringify(conflict)}`);
assert.equal(conflict.payload?.plan?.requiresConfirmation, true);
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.title, `${prefix}-same-name`);
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.existingDocumentId, existing);
const normalCopy = await runDropPreflight(request, {
...basePayload,
copy: true,
sourceCapabilities: ["read"],
targetChildren: [],
});
assert.equal(normalCopy.ok, true, `copy modifier 不应被 readonly source move 规则误拒绝: ${JSON.stringify(normalCopy)}`);
assert.equal(normalCopy.payload?.plan?.copy, true);
assert.equal(normalCopy.payload?.plan?.requiresConfirmation ?? false, false);
result.checks.push(
{ area: "readonly-target", ok: true, status: readonlyTarget.status },
{ area: "readonly-source", ok: true, status: readonlySource.status },
{ area: "conflict-confirmation-plan", ok: true, conflict: conflict.payload.plan.conflicts[0] },
{ area: "copy-modifier-no-false-positive", ok: true, plan: normalCopy.payload.plan },
);
result.workspaceId = workspaceId;
result.fixture = { target, source, existing };
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
process.exitCode = 1;
} finally {
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();