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:
lix-2026
2026-05-19 08:07:17 +08:00
parent a2cb1338c8
commit 68d321e297
36 changed files with 8643 additions and 235 deletions
+294
View File
@@ -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;
});