feat: purge legacy agent hosts and land vault Chrome extension path
Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault extension + extension token route, pre-release purge design, and soft-retire legacy smokes for the small-group production cut.
This commit is contained in:
@@ -1,380 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const OUTPUT_DIR = path.resolve(__dirname, "..", "tmp", "task490-runtime-surfaces-smoke");
|
||||
const TASK = "task490-runtime-surfaces-smoke";
|
||||
const TARGET_BLOCK_ID = "task490-runtime-surface-target";
|
||||
|
||||
function screenshotPath(name) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
return path.join(OUTPUT_DIR, `${name}.png`);
|
||||
}
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(baseUrl, root, relativePath) {
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
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}:task490`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function saveScreenshot(page, name) {
|
||||
const target = screenshotPath(name);
|
||||
await page.screenshot({ path: target, fullPage: false });
|
||||
return target;
|
||||
}
|
||||
|
||||
async function assertMaterialIconSurface(locator, label, minCount) {
|
||||
const iconCount = await locator.locator('.material-symbols-outlined, .slash-item-icon, .block-drag-menu-icon').count();
|
||||
if (iconCount < minCount) {
|
||||
throw new Error(`${label} 至少应渲染 ${minCount} 个图标容器,实际: ${iconCount}`);
|
||||
}
|
||||
const text = ((await locator.textContent()) || '').trim();
|
||||
const legacyGlyphs = ['⚙', '×', '✕', '⋮', '◣', '↻'];
|
||||
const leaked = legacyGlyphs.filter((glyph) => text.includes(glyph));
|
||||
if (leaked.length > 0) {
|
||||
throw new Error(`${label} 不应泄漏旧 Unicode 图标: ${leaked.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page, uiTimeoutMs) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
await editor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error"
|
||||
&& editorNode instanceof HTMLElement
|
||||
&& editorNode.isContentEditable;
|
||||
},
|
||||
null,
|
||||
{ timeout: uiTimeoutMs },
|
||||
);
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function setBlockHandleFixture(page, uiTimeoutMs) {
|
||||
await page.evaluate((targetBlockId) => {
|
||||
const editor = document.querySelector(".editor-surface .ProseMirror")?.editor;
|
||||
if (!editor) {
|
||||
throw new Error("找不到 Tiptap editor");
|
||||
}
|
||||
editor.commands.setContent(
|
||||
{
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: "text", text: "Task490 block handle target" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "task490-second-block" },
|
||||
content: [{ type: "text", text: "Task490 slash target" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
editor.commands.focus("end");
|
||||
}, TARGET_BLOCK_ID);
|
||||
|
||||
await page.waitForFunction(
|
||||
(targetBlockId) => document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement,
|
||||
TARGET_BLOCK_ID,
|
||||
{ timeout: uiTimeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
async function closePageAiIfOpen(page) {
|
||||
const closeButton = page.locator('[data-page-ai-action="close"]').first();
|
||||
if (await closeButton.isVisible().catch(() => false)) {
|
||||
await closeButton.click({ force: true }).catch(async () => {
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-page-ai-action="close"]');
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
delete require.cache[require.resolve("./tree-shell-smoke-helpers")];
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
ensureAuthenticated,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const screenshots = {};
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task490-"));
|
||||
const relativePath = "RuntimeSurfaces.md";
|
||||
const capturedPageAi = {
|
||||
runs: [],
|
||||
aborts: [],
|
||||
};
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.writeFileSync(
|
||||
path.join(root, relativePath),
|
||||
["# Runtime Surfaces", "", "Task490 initial paragraph", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
gateway: { ok: true, status: "mocked" },
|
||||
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
|
||||
suggestions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/tools**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, tools: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/profiles**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
active: "mnoteai",
|
||||
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ai/agent-profiles**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
agentId: "reasonix",
|
||||
profiles: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: "mnote_task490",
|
||||
title: "task490",
|
||||
traceId: "trace_task490",
|
||||
persistence: "local_ai_session_jsonl",
|
||||
sessionStorage: "local_private",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
capturedPageAi.runs.push(route.request().postData() || "");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: "mnote_task490",
|
||||
runId: "run_task490_stop",
|
||||
upstream: { run_id: "run_task490_stop", trace_id: "trace_task490_stop" },
|
||||
traceId: "trace_task490_stop",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/events/run_task490_stop", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, UI_TIMEOUT_MS * 2));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task490_stop", delta: "Task490 streaming" })}\n\n`,
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs/run_task490_stop/abort", async (route) => {
|
||||
capturedPageAi.aborts.push(route.request().postData() || "");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
status: "aborted",
|
||||
runtime: { runId: "run_task490_stop", status: "aborted", queueLength: 0 },
|
||||
events: [
|
||||
{ event: "abort.started", runId: "run_task490_stop" },
|
||||
{ event: "abort.completed", runId: "run_task490_stop" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
|
||||
const url = documentUrl(BASE_URL, root, relativePath);
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert(response.status() === 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
const editModeToggle = page.locator('[data-document-edit-mode-toggle]').first();
|
||||
if (await editModeToggle.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await editModeToggle.click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
const editor = await waitForRuntimeIsland(page, UI_TIMEOUT_MS);
|
||||
|
||||
const moreButton = page.locator('[data-testid="wolai-page-settings-trigger"]').first();
|
||||
await moreButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await moreButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const settingsPopover = page.locator('[data-testid="wolai-page-settings-popover"]').first();
|
||||
await settingsPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const settingsText = (await settingsPopover.textContent()) || "";
|
||||
assert(settingsText.includes("页面选项"), `页面设置 popover 未显示页面选项: ${settingsText}`);
|
||||
screenshots.pageSettings = await saveScreenshot(page, "01-page-settings");
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
|
||||
const aiButton = page.locator('[data-testid="wolai-floating-ai"]').first();
|
||||
await aiButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const floatingAiText = ((await aiButton.textContent()) || "").trim();
|
||||
assert(floatingAiText === "π", `浮动 AI 入口应退役旧 OpenHub 前端并显示 Pi Lab π: ${floatingAiText}`);
|
||||
await aiButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const piDrawer = page.locator('[data-page-ai-pi-lab="drawer"]').first();
|
||||
await piDrawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert(await page.locator("[data-page-ai-pi-lab-input]").first().isVisible(), "Pi Lab drawer 必须显示输入框");
|
||||
const retiredFrontend = await page.evaluate(() => ({
|
||||
legacyDrawerOpen: Boolean(document.querySelector('[data-testid="wolai-page-ai-drawer"]:not([hidden])')),
|
||||
opencodeHost: Boolean(document.querySelector('[data-page-ai-opencode-host="true"]')),
|
||||
opencodeIframe: Boolean(document.querySelector('[data-page-ai-opencode-iframe]')),
|
||||
}));
|
||||
assert(retiredFrontend.legacyDrawerOpen === false, "浮动 Pi 入口不能打开旧 Page AI/OpenHub drawer");
|
||||
assert(retiredFrontend.opencodeHost === false, "浮动 Pi 入口不能打开 opencode/OpenHub host 前端");
|
||||
assert(retiredFrontend.opencodeIframe === false, "浮动 Pi 入口不能保留 opencode/OpenHub iframe");
|
||||
screenshots.pageAi = await saveScreenshot(page, "02-pi-lab");
|
||||
await page.locator("[data-page-ai-pi-lab-close]").click({ timeout: UI_TIMEOUT_MS });
|
||||
await piDrawer.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await setBlockHandleFixture(page, UI_TIMEOUT_MS);
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type("/");
|
||||
const slashMenu = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
|
||||
await slashMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const slashText = (await slashMenu.textContent()) || "";
|
||||
assert(slashText.trim().length > 0, "slash menu 必须显示可选项");
|
||||
await assertMaterialIconSurface(slashMenu, "slash menu", 4);
|
||||
screenshots.slashMenu = await saveScreenshot(page, "03-slash-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
const targetBox = await target.boundingBox();
|
||||
assert(targetBox, "block handle 目标块缺少可测量区域");
|
||||
await page.mouse.move(targetBox.x + 8, targetBox.y + Math.min(12, Math.max(4, targetBox.height / 2)));
|
||||
const blockHandle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await blockHandle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await blockHandle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const blockMenu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await blockMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const blockMenuText = (await blockMenu.textContent()) || "";
|
||||
assert(blockMenuText.includes("删除") || blockMenuText.includes("Delete"), `block handle menu 缺少基础操作: ${blockMenuText}`);
|
||||
await assertMaterialIconSurface(blockMenu, "block handle menu", 8);
|
||||
screenshots.blockHandleMenu = await saveScreenshot(page, "04-block-handle-menu");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
relativePath,
|
||||
documentId: localMdDocumentId(relativePath),
|
||||
screenshotDir: OUTPUT_DIR,
|
||||
screenshots,
|
||||
};
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
await saveScreenshot(page, "failure").catch(() => undefined);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* RETIRED (Wave 10 / pre-release legacy purge)
|
||||
* 依赖已删除的 /api/hermes/client/* 或 OpenCode Page AI host。
|
||||
* 产品 Page AI 仅 Pi Lab(/api/page-ai/pi/*);agent tools 为 /api/mnote/tools/*。
|
||||
* 本文件保留作历史对照,直接 exit 0,不再执行浏览器/静态断言。
|
||||
*/
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
retired: true,
|
||||
reason: "legacy hermes client / opencode page-ai path deleted; use Pi Lab + /api/mnote/tools",
|
||||
script: require("node:path").basename(__filename),
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
|
||||
Reference in New Issue
Block a user