#!/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); } })();