feat: advance local-first workspace checklist
- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const WAIVER = "local-first-allow-convex-main-storage";
|
||||
|
||||
const RUNTIME_FORBIDDEN = [
|
||||
{
|
||||
name: "未标注的 Convex documents 直连",
|
||||
pattern: /documents:(?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex media 直连",
|
||||
pattern: /mediaAssets:(?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex AI session 直连",
|
||||
pattern:
|
||||
/aiSessions:(?:upsertRuntimeRun|appendRuntimeEvent|getRuntimeRun|listRuntimeRuns|listRuntimeEvents|renameRuntimeSession|autoTitleRuntimeSession|deleteRuntimeSession|searchRuntimeSessions)\b/,
|
||||
},
|
||||
{
|
||||
name: "Convex media URL 主路径",
|
||||
pattern: /\/api\/media\/(?:upload|sign|batch)|assetId=/,
|
||||
},
|
||||
];
|
||||
|
||||
const DESIGN_DEFAULT_CONVEX = /(?:Convex|convex).*(?:默认|default).*(?:主存储|主数据层|数据真相|页面正文真相|文件树真相|附件|AI 会话|session)/;
|
||||
const DESIGN_NEGATION =
|
||||
/(?:不应|不得|不再|禁止|只作为|降级|控制面|检查|若把|应要求改成|不是|不作为|不依赖|可选|sync replica|cloud source|compat|依赖证据|代码盘点|未完成|部分完成|当前状态)/;
|
||||
const ACTIVE_CONVEX_FORBIDDEN = [
|
||||
{
|
||||
name: "未标注的 Convex documents 表",
|
||||
pattern: /\bdocuments\s*:\s*defineTable\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex media_assets 表",
|
||||
pattern: /\bmedia_assets\s*:\s*defineTable\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex documents 函数",
|
||||
pattern: /export const (?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge|listByWorkspace)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex mediaAssets 函数",
|
||||
pattern: /export const (?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
|
||||
},
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"用法:",
|
||||
" node scripts/check-local-first-convex-guard.js",
|
||||
" node scripts/check-local-first-convex-guard.js --base origin/main",
|
||||
" node scripts/check-local-first-convex-guard.js --staged",
|
||||
" node scripts/check-local-first-convex-guard.js --files <path...>",
|
||||
" node scripts/check-local-first-convex-guard.js --self-test",
|
||||
"",
|
||||
`如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
base: "HEAD",
|
||||
staged: false,
|
||||
files: [],
|
||||
selfTest: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--staged") {
|
||||
result.staged = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--self-test") {
|
||||
result.selfTest = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base") {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error("--base 需要一个 git ref");
|
||||
result.base = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--files") {
|
||||
result.files = argv.slice(index + 1);
|
||||
break;
|
||||
}
|
||||
throw new Error(`未知参数:${arg}\n${usage()}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isDesignPath(filePath) {
|
||||
return filePath.startsWith("design/") && !filePath.startsWith("design/old/") && filePath.endsWith(".md");
|
||||
}
|
||||
|
||||
function isRuntimePath(filePath) {
|
||||
return filePath.startsWith("rust/crates/mnote-web/src/") && filePath.endsWith(".rs");
|
||||
}
|
||||
|
||||
function isActiveConvexPath(filePath) {
|
||||
return filePath.startsWith("convex/") && filePath.endsWith(".ts") && !filePath.startsWith("convex/_generated/");
|
||||
}
|
||||
|
||||
function isAllowedRuntimeAdapter(filePath) {
|
||||
return (
|
||||
filePath === "rust/crates/mnote-web/src/transport/convex.rs" ||
|
||||
filePath === "rust/crates/mnote-web/src/routes/compat.rs"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function readDiffLines(options) {
|
||||
const args = ["diff", "--unified=0", "--no-ext-diff"];
|
||||
if (options.staged) {
|
||||
args.push("--cached");
|
||||
} else {
|
||||
args.push(options.base);
|
||||
}
|
||||
args.push("--");
|
||||
const output = execFileSync("git", args, { encoding: "utf8" });
|
||||
return output.split(/\r?\n/);
|
||||
}
|
||||
|
||||
function collectAddedLinesFromDiff(lines) {
|
||||
const added = [];
|
||||
let currentFile = null;
|
||||
let newLine = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
|
||||
if (fileMatch) {
|
||||
currentFile = fileMatch[1];
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+++ /dev/null")) {
|
||||
currentFile = null;
|
||||
continue;
|
||||
}
|
||||
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunkMatch) {
|
||||
newLine = Number(hunkMatch[1]);
|
||||
continue;
|
||||
}
|
||||
if (!currentFile || line.startsWith("diff --git") || line.startsWith("index ")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
added.push({ filePath: currentFile, lineNumber: newLine, text: line.slice(1) });
|
||||
newLine += 1;
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("-")) {
|
||||
newLine += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
function collectLinesFromFiles(files) {
|
||||
const rows = [];
|
||||
for (const rawPath of files) {
|
||||
const filePath = normalizePath(rawPath);
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
rows.push({ filePath, lineNumber: index + 1, text: line });
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkLine(row) {
|
||||
if (row.text.includes(WAIVER)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isDesignPath(row.filePath)) {
|
||||
if (DESIGN_DEFAULT_CONVEX.test(row.text) && !DESIGN_NEGATION.test(row.text)) {
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
reason: "设计稿新增口径把 Convex 描述成默认主存储;请改成 LocalFS/WorkspaceSource 默认,或明确标注 cloud/control-plane/compat。",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) {
|
||||
if (!isActiveConvexPath(row.filePath)) {
|
||||
return [];
|
||||
}
|
||||
return ACTIVE_CONVEX_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
...row,
|
||||
reason: `${entry.name} 出现在 active convex 目录;新增 Convex 代码只能是 auth、membership、share grants、sync state、AI policy、cloud source、compat 或 sync replica,并需用 ${WAIVER} 标注例外。`,
|
||||
}));
|
||||
}
|
||||
|
||||
return RUNTIME_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
...row,
|
||||
reason: `${entry.name} 出现在非 Convex adapter 路径;请先走 WorkspaceSource / Rust kernel / LocalFS executor,或加 ${WAIVER} 并说明原因。`,
|
||||
}));
|
||||
}
|
||||
|
||||
function runSelfTest() {
|
||||
const rows = [
|
||||
{
|
||||
filePath: "convex/schema.ts",
|
||||
lineNumber: 1,
|
||||
text: " documents: defineTable({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/mediaAssets.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const generateUploadUrl = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/shareGrants.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const upsertShareGrant = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "rust/crates/mnote-web/src/routes/example.rs",
|
||||
lineNumber: 1,
|
||||
text: '"documents:updateContent"',
|
||||
},
|
||||
{
|
||||
filePath: "rust/crates/mnote-web/src/transport/convex.rs",
|
||||
lineNumber: 1,
|
||||
text: '"documents:updateContent"',
|
||||
},
|
||||
];
|
||||
const violations = rows.flatMap(checkLine);
|
||||
const labels = violations.map((item) => item.filePath);
|
||||
if (!labels.includes("convex/schema.ts")) {
|
||||
throw new Error("self-test 失败:未拦截 active convex documents 表");
|
||||
}
|
||||
if (!labels.includes("convex/mediaAssets.ts")) {
|
||||
throw new Error("self-test 失败:未拦截 active convex media 函数");
|
||||
}
|
||||
if (!labels.includes("rust/crates/mnote-web/src/routes/example.rs")) {
|
||||
throw new Error("self-test 失败:未拦截非 adapter Rust Convex 直连");
|
||||
}
|
||||
if (labels.includes("convex/shareGrants.ts")) {
|
||||
throw new Error("self-test 失败:误拦截控制面 share grants");
|
||||
}
|
||||
if (labels.includes("rust/crates/mnote-web/src/transport/convex.rs")) {
|
||||
throw new Error("self-test 失败:误拦截 Convex adapter");
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, guard: "local-first-convex", selfTest: true }, null, 2));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.selfTest) {
|
||||
runSelfTest();
|
||||
return;
|
||||
}
|
||||
const rows = options.files.length > 0 ? collectLinesFromFiles(options.files) : collectAddedLinesFromDiff(readDiffLines(options));
|
||||
const violations = rows.flatMap(checkLine);
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("local-first Convex guard 发现新增主存储绑定:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.filePath}:${violation.lineNumber} ${violation.reason}`);
|
||||
console.error(` ${violation.text.trim()}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, checkedLines: rows.length, guard: "local-first-convex" }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000";
|
||||
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000);
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function fileUrl(filePath) {
|
||||
return `file://${filePath.split(path.sep).map((part, index) => (
|
||||
index === 0 ? "" : encodeURIComponent(part)
|
||||
)).join("/")}`;
|
||||
}
|
||||
|
||||
async function waitForText(page, selector, expected) {
|
||||
await page.waitForFunction(
|
||||
({ selector: targetSelector, expectedText }) => {
|
||||
const node = document.querySelector(targetSelector);
|
||||
return Boolean(node && node.textContent && node.textContent.includes(expectedText));
|
||||
},
|
||||
{ selector, expectedText: expected },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return page.locator(selector).innerText({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-admin-access-policy-"));
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "README.md"), "# Admin Smoke\n", "utf8");
|
||||
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
|
||||
const adminContext = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "admin_smoke",
|
||||
"x-mnote-actor-type": "admin",
|
||||
},
|
||||
});
|
||||
const adminPage = await adminContext.newPage();
|
||||
const readerContext = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "reader_smoke",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const readerPage = await readerContext.newPage();
|
||||
|
||||
const grantId = `grant_${Date.now()}`;
|
||||
const rootUri = fileUrl(root);
|
||||
|
||||
try {
|
||||
await adminPage.goto(`${BASE_URL}/admin/access-policy`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(
|
||||
await adminPage.locator('[data-testid="mnote-admin-policy-path"]').innerText(),
|
||||
"管理页应显示策略路径",
|
||||
);
|
||||
|
||||
await adminPage.locator('[data-testid="mnote-admin-root-uri"]').fill(rootUri);
|
||||
await adminPage.locator('[data-testid="mnote-admin-validate-root-submit"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const validateResult = await waitForText(
|
||||
adminPage,
|
||||
'[data-testid="mnote-admin-validate-result"]',
|
||||
root,
|
||||
);
|
||||
assert(validateResult.includes(root), "验证目录结果应包含 canonical root");
|
||||
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-id"]').fill(grantId);
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-user-id"]').fill("reader_smoke");
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-root-uri"]').fill(rootUri);
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-permission"]').selectOption("read");
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-recursive"]').check();
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-capabilities"]').fill("ai,share");
|
||||
await adminPage.locator('[data-testid="mnote-admin-create-grant-submit"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const createResult = await waitForText(
|
||||
adminPage,
|
||||
'[data-testid="mnote-admin-create-result"]',
|
||||
grantId,
|
||||
);
|
||||
assert(createResult.includes(grantId), "创建结果应返回 grantId");
|
||||
|
||||
await readerPage.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const openResponse = await readerPage.evaluate(async ({ rootUriValue }) => {
|
||||
const url = new URL("/api/local-folder/files/open", window.location.origin);
|
||||
url.searchParams.set("rootUri", rootUriValue);
|
||||
url.searchParams.set("path", "README.md");
|
||||
const response = await fetch(url.toString(), { credentials: "include" });
|
||||
return { status: response.status, text: await response.text() };
|
||||
}, { rootUriValue: rootUri });
|
||||
assert.equal(openResponse.status, 200, "read grant 应可打开本地文件");
|
||||
assert(openResponse.text.includes("Admin Smoke"), "打开的文件内容应正确");
|
||||
|
||||
const writeResponse = await readerPage.evaluate(async ({ rootUriValue }) => {
|
||||
const response = await fetch("/api/page-body/write", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
documentId: "local-md:README.md",
|
||||
workspaceId: "local-ws-admin-smoke",
|
||||
sourceKind: "local_folder",
|
||||
rootUri: rootUriValue,
|
||||
contentFormat: "editorBlocks",
|
||||
content: [],
|
||||
}),
|
||||
});
|
||||
return { status: response.status, text: await response.text() };
|
||||
}, { rootUriValue: rootUri });
|
||||
assert.equal(writeResponse.status, 403, "read grant 不应允许写入");
|
||||
|
||||
await adminPage.locator('[data-testid="mnote-admin-delete-grant-id"]').fill(grantId);
|
||||
await adminPage.locator('[data-testid="mnote-admin-delete-grant-submit"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const deleteResult = await waitForText(
|
||||
adminPage,
|
||||
'[data-testid="mnote-admin-delete-result"]',
|
||||
grantId,
|
||||
);
|
||||
assert(deleteResult.includes(grantId), "删除结果应返回 grantId");
|
||||
|
||||
await adminPage.waitForFunction(
|
||||
({ selector, removedGrantId }) => {
|
||||
const node = document.querySelector(selector);
|
||||
return Boolean(node && node.textContent && !node.textContent.includes(removedGrantId));
|
||||
},
|
||||
{ selector: '[data-testid="mnote-admin-policy-json"]', removedGrantId: grantId },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const policyJson = await adminPage.locator('[data-testid="mnote-admin-policy-json"]').innerText();
|
||||
assert(!policyJson.includes(grantId), "删除后策略面板不应再包含已删授权");
|
||||
} finally {
|
||||
await adminContext.close().catch(() => {});
|
||||
await readerContext.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task451-local-markdown-conflict-resolution-ui-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
const debug = {};
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function markdown(title, lines) {
|
||||
return [
|
||||
"---",
|
||||
`title: ${title}`,
|
||||
"---",
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task451`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, text) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (editor?.textContent || "").includes(expected);
|
||||
},
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForEditorStatus(page, status) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return root?.getAttribute("data-runtime-editor-status") === expected;
|
||||
},
|
||||
status,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function typeDirtyText(page, text) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type(text, { delay: 8 });
|
||||
await waitForEditorText(page, text.trim());
|
||||
}
|
||||
|
||||
async function waitForFileText(filePath, expected) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||
if (content.includes(expected)) return content;
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
throw new Error(`文件未出现期望内容: ${expected}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-conflict-ui-"));
|
||||
const acceptFile = "accept-disk.md";
|
||||
const keepFile = "keep-current.md";
|
||||
writeWorkspaceManifest(root, "user_real");
|
||||
fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept"]), "utf8");
|
||||
fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep"]), "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 860 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
debug.network = [];
|
||||
page.on("response", async (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return;
|
||||
let body = "";
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch (_) {
|
||||
body = "";
|
||||
}
|
||||
debug.network.push({ url, status: response.status(), body: body.slice(0, 800) });
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return;
|
||||
debug.network.push({ url, failed: request.failure()?.errorText || "request_failed" });
|
||||
});
|
||||
const steps = [];
|
||||
|
||||
try {
|
||||
await openDocument(page, root, acceptFile);
|
||||
await waitForEditorText(page, "initial accept");
|
||||
const localAcceptToken = `local-accept-${Date.now()}`;
|
||||
const diskAcceptToken = `disk-accept-${Date.now()}`;
|
||||
await typeDirtyText(page, ` ${localAcceptToken}`);
|
||||
fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept", diskAcceptToken]), "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
return (panel?.textContent || "").includes(expected);
|
||||
},
|
||||
diskAcceptToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
debug.acceptDiffText = diffText;
|
||||
assert(diffText.includes(localAcceptToken), "diff 应显示当前编辑器版本");
|
||||
assert(diffText.includes(diskAcceptToken), "diff 应显示磁盘版本");
|
||||
await page.locator('[data-testid="mnote-conflict-accept-disk"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await waitForEditorText(page, diskAcceptToken);
|
||||
steps.push({ label: "accept-disk", ok: true });
|
||||
|
||||
await openDocument(page, root, keepFile);
|
||||
await waitForEditorText(page, "initial keep");
|
||||
const localKeepToken = `local-keep-${Date.now()}`;
|
||||
const diskKeepToken = `disk-keep-${Date.now()}`;
|
||||
await typeDirtyText(page, ` ${localKeepToken}`);
|
||||
fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep", diskKeepToken]), "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-keep-current"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await waitForFileText(path.join(root, keepFile), localKeepToken);
|
||||
const keepContent = fs.readFileSync(path.join(root, keepFile), "utf8");
|
||||
assert(!keepContent.includes(diskKeepToken), "保留当前版本后磁盘版本内容不应覆盖当前编辑器内容");
|
||||
steps.push({ label: "keep-current", ok: true });
|
||||
|
||||
const result = { ok: true, baseUrl: BASE_URL, root, steps };
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(`task451 local markdown conflict resolution UI smoke passed: ${RESULT_PATH}`);
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
|
||||
ok: false,
|
||||
error: String(error && error.stack || error),
|
||||
debug,
|
||||
}, null, 2)}\n`, "utf8");
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user