296 lines
9.9 KiB
JavaScript
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);
|
||
|
|
}
|
||
|
|
})();
|