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。
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
#!/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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user