chore: align local-first control plane and editor fixes
- wire SQLite control-plane access/session paths into Rust web local-folder routes - preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs - refresh design governance docs, Reasonix task templates, and bug records - retire root .mcp.json local MCP config
This commit is contained in:
@@ -11,6 +11,9 @@ const {
|
||||
UI_TIMEOUT_MS,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task453-local-folder-page-ai-changed-files-smoke";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
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));
|
||||
@@ -19,6 +22,10 @@ function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -33,16 +40,76 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchPageAggregate(page, documentId, rootUri) {
|
||||
return await page.evaluate(async ({ id, uri }) => {
|
||||
const url = new URL(`/api/page-aggregate/${encodeURIComponent(id)}`, window.location.origin);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", uri);
|
||||
const response = await fetch(url.toString(), { headers: { accept: "application/json" } });
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
payload: await response.json().catch(() => null),
|
||||
};
|
||||
}, { id: documentId, uri: rootUri });
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, expected) {
|
||||
await page.waitForFunction(
|
||||
(text) => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (editor?.textContent || "").includes(text);
|
||||
},
|
||||
expected,
|
||||
{ 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 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 conflictEnvelope(page, documentId) {
|
||||
return await page.evaluate((docId) => {
|
||||
const snapshot = window.__mnoteDebugDocumentSessions?.snapshot?.();
|
||||
if (!snapshot) return null;
|
||||
const session = snapshot.sessions.find((item) => item.documentId === docId);
|
||||
return session?.lastExternalConflictEnvelope || null;
|
||||
}, documentId);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-ai-changed-files-"));
|
||||
const documentId = "local-md:README.md";
|
||||
const documentId = localMdDocumentId("README.md");
|
||||
const dirtyDocumentId = localMdDocumentId("Dirty.md");
|
||||
const actorId = "user_real";
|
||||
const sessionId = `mnote_local_ai_changed_${suffix}`;
|
||||
const runId = `run_local_ai_changed_${suffix}`;
|
||||
const dirtySessionId = `mnote_local_ai_dirty_${suffix}`;
|
||||
const dirtyRunId = `run_local_ai_dirty_${suffix}`;
|
||||
const marker = `LOCAL-AI-CHANGED-FILES-${suffix}`;
|
||||
const dirtyMarker = `LOCAL-AI-DIRTY-FILES-${suffix}`;
|
||||
const dirtyLocalToken = `LOCAL-UNSAVED-DIRTY-${suffix}`;
|
||||
const readmePath = path.join(root, "README.md");
|
||||
const dirtyPath = path.join(root, "Dirty.md");
|
||||
const captured = [];
|
||||
let currentScenario = "clean";
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
@@ -61,11 +128,34 @@ async function main() {
|
||||
const workspaceId = `local-ws:${actorId}:task453`;
|
||||
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||
fs.writeFileSync(readmePath, `# Local AI Changed Files\n初始内容 ${suffix}\n`, "utf8");
|
||||
fs.writeFileSync(dirtyPath, `# Dirty AI Changed Files\n初始 dirty 内容 ${suffix}\n`, "utf8");
|
||||
const rootUri = fileUrl(root);
|
||||
const scenarioConfig = () => currentScenario === "dirty"
|
||||
? {
|
||||
sessionId: dirtySessionId,
|
||||
runId: dirtyRunId,
|
||||
documentId: dirtyDocumentId,
|
||||
filePath: dirtyPath,
|
||||
relativePath: "Dirty.md",
|
||||
marker: dirtyMarker,
|
||||
message: "已修改本地 Dirty。",
|
||||
}
|
||||
: {
|
||||
sessionId,
|
||||
runId,
|
||||
documentId,
|
||||
filePath: readmePath,
|
||||
relativePath: "README.md",
|
||||
marker,
|
||||
message: "已修改本地 README。",
|
||||
};
|
||||
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/documents/save", async (route) => {
|
||||
throw new Error(`local-first AI smoke 不应请求 compat /api/documents/save: ${route.request().url()}`);
|
||||
});
|
||||
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -97,13 +187,14 @@ async function main() {
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
const scenario = scenarioConfig();
|
||||
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
sessionId: scenario.sessionId,
|
||||
title: "本地 changed files",
|
||||
traceId: `trace_local_changed_${suffix}`,
|
||||
persistence: "local_ai_session_jsonl",
|
||||
@@ -111,35 +202,37 @@ async function main() {
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||
await page.route("**/api/hermes/client/sessions/*/resume", async (route) => {
|
||||
const scenario = scenarioConfig();
|
||||
captured.push({ kind: "session-resume", method: route.request().method(), body: "" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
session: { sessionId, messages: [] },
|
||||
sessionId: scenario.sessionId,
|
||||
session: { sessionId: scenario.sessionId, messages: [] },
|
||||
runtime: {
|
||||
sessionId,
|
||||
runId,
|
||||
sessionId: scenario.sessionId,
|
||||
runId: scenario.runId,
|
||||
status: "completed",
|
||||
profile: "reasonix",
|
||||
documentId,
|
||||
documentId: scenario.documentId,
|
||||
traceId: `trace_local_changed_resume_${suffix}`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
const scenario = scenarioConfig();
|
||||
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId,
|
||||
runId,
|
||||
sessionId: scenario.sessionId,
|
||||
runId: scenario.runId,
|
||||
events: [],
|
||||
traceId: `trace_local_changed_run_${suffix}`,
|
||||
persistence: "local_ai_session_jsonl",
|
||||
@@ -147,28 +240,29 @@ async function main() {
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
|
||||
await page.route("**/api/hermes/client/events/*", async (route) => {
|
||||
const scenario = scenarioConfig();
|
||||
captured.push({ kind: "events", method: route.request().method(), body: "" });
|
||||
fs.appendFileSync(readmePath, `\nAI 写入标记:${marker}\n`, "utf8");
|
||||
fs.appendFileSync(scenario.filePath, `\nAI 写入标记:${scenario.marker}\n`, "utf8");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "已修改本地 README。" })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: scenario.runId, session_id: scenario.sessionId, delta: scenario.message })}\n\n` +
|
||||
`data: ${JSON.stringify({
|
||||
event: "run.completed",
|
||||
run_id: runId,
|
||||
session_id: sessionId,
|
||||
output: "已修改本地 README。",
|
||||
run_id: scenario.runId,
|
||||
session_id: scenario.sessionId,
|
||||
output: scenario.message,
|
||||
agentAudit: {
|
||||
eventId: `audit_local_changed_${suffix}`,
|
||||
eventId: `audit_local_changed_${currentScenario}_${suffix}`,
|
||||
rootUri,
|
||||
diffSummary: "1 changed file(s)",
|
||||
changedFiles: [
|
||||
{
|
||||
path: "README.md",
|
||||
path: scenario.relativePath,
|
||||
changeType: "modified",
|
||||
summary: `追加 ${marker}`,
|
||||
summary: `追加 ${scenario.marker}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -217,19 +311,112 @@ async function main() {
|
||||
),
|
||||
`本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`,
|
||||
);
|
||||
assert(fs.readFileSync(readmePath, "utf8").includes(marker), "本地 README.md 未写入 smoke 标记");
|
||||
assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
root,
|
||||
documentId,
|
||||
sessionId,
|
||||
runId,
|
||||
marker,
|
||||
capturedKinds: captured.map((entry) => entry.kind),
|
||||
}, null, 2),
|
||||
const diskText = fs.readFileSync(readmePath, "utf8");
|
||||
assert(diskText.includes(marker), "本地 README.md 未写入 smoke 标记");
|
||||
const aggregate = await fetchPageAggregate(page, documentId, rootUri);
|
||||
assert.equal(aggregate.ok, true, `Page Aggregate 应能读取 local_folder 文档: ${JSON.stringify(aggregate)}`);
|
||||
assert(
|
||||
JSON.stringify(aggregate.payload || {}).includes(marker),
|
||||
`Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(aggregate)}`,
|
||||
);
|
||||
await waitForEditorText(page, marker);
|
||||
const editorState = await page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
|
||||
return {
|
||||
status: root?.getAttribute("data-runtime-editor-status") || "",
|
||||
text: editor?.textContent || "",
|
||||
conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0),
|
||||
};
|
||||
});
|
||||
assert.notEqual(editorState.status, "external-change-conflict", `clean AI 写入不应触发冲突态: ${JSON.stringify(editorState)}`);
|
||||
assert.equal(editorState.conflictVisible, false, `clean AI 写入不应显示冲突面板: ${JSON.stringify(editorState)}`);
|
||||
assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
|
||||
const runBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
|
||||
assert.equal(runBody.documentId, documentId, `Hermes run 应携带 local documentId: ${JSON.stringify(runBody)}`);
|
||||
assert.equal(runBody.sourceKind, "local_folder", `Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(runBody)}`);
|
||||
assert.equal(runBody.rootUri, rootUri, `Hermes run 应携带 rootUri: ${JSON.stringify(runBody)}`);
|
||||
const cleanCapturedKinds = captured.map((entry) => entry.kind);
|
||||
|
||||
currentScenario = "dirty";
|
||||
captured.length = 0;
|
||||
const dirtyUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(dirtyDocumentId)}`);
|
||||
dirtyUrl.searchParams.set("sourceKind", "local_folder");
|
||||
dirtyUrl.searchParams.set("rootUri", rootUri);
|
||||
await page.goto(dirtyUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForEditorText(page, "Dirty AI Changed Files");
|
||||
await typeDirtyText(page, ` ${dirtyLocalToken}`);
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`请修改 Dirty 并记录 changed files ${dirtyMarker}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return drawerText.includes("agent.changed_files") && drawerText.includes("Dirty.md");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
|
||||
return (panel?.textContent || "").includes(expected);
|
||||
},
|
||||
`agent run ${dirtyRunId}`,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const envelope = await conflictEnvelope(page, dirtyDocumentId);
|
||||
assert(envelope, "dirty AI 写入应生成冲突信封");
|
||||
assert("externalActor" in envelope, `冲突信封应包含 externalActor: ${JSON.stringify(envelope)}`);
|
||||
assert("dirtyState" in envelope, `冲突信封应包含 dirtyState: ${JSON.stringify(envelope)}`);
|
||||
assert("bufferFileVersion" in envelope, `冲突信封应包含 bufferFileVersion: ${JSON.stringify(envelope)}`);
|
||||
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(
|
||||
({ localToken, aiToken }) => {
|
||||
const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
const text = panel?.textContent || "";
|
||||
return text.includes(localToken) && text.includes(aiToken);
|
||||
},
|
||||
{ localToken: dirtyLocalToken, aiToken: dirtyMarker },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(diffText.includes(dirtyLocalToken), `dirty diff 应包含本地未保存内容: ${diffText}`);
|
||||
assert(diffText.includes(dirtyMarker), `dirty diff 应包含 AI 写盘内容: ${diffText}`);
|
||||
const dirtyAggregate = await fetchPageAggregate(page, dirtyDocumentId, rootUri);
|
||||
assert(
|
||||
JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
|
||||
`dirty Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
|
||||
);
|
||||
const dirtyRunBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
|
||||
assert.equal(dirtyRunBody.documentId, dirtyDocumentId, `dirty Hermes run 应携带 local documentId: ${JSON.stringify(dirtyRunBody)}`);
|
||||
assert.equal(dirtyRunBody.sourceKind, "local_folder", `dirty Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(dirtyRunBody)}`);
|
||||
assert.equal(dirtyRunBody.rootUri, rootUri, `dirty Hermes run 应携带 rootUri: ${JSON.stringify(dirtyRunBody)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
root,
|
||||
documentId,
|
||||
sessionId,
|
||||
runId,
|
||||
marker,
|
||||
aggregateRevision: aggregate.payload?.result?.body?.revision ?? aggregate.payload?.body?.revision ?? null,
|
||||
editorStatus: editorState.status,
|
||||
dirtyDocumentId,
|
||||
dirtyRunId,
|
||||
dirtyMarker,
|
||||
dirtyConflictStatus: "external-change-conflict",
|
||||
dirtyEnvelope: envelope,
|
||||
capturedKinds: cleanCapturedKinds,
|
||||
dirtyCapturedKinds: captured.map((entry) => entry.kind),
|
||||
resultPath: RESULT_PATH,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user