advance 1-8 post-mvp execution batches
This commit is contained in:
@@ -26,6 +26,8 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
|
||||
const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1';
|
||||
@@ -162,8 +164,26 @@ process.env.DEEPSEEK_API_KEY = DEEPSEEK_API_KEY;
|
||||
// ── Dynamic import of reasonix public API ────────────
|
||||
|
||||
let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix;
|
||||
async function importReasonix() {
|
||||
try {
|
||||
return await import('reasonix');
|
||||
} catch (error) {
|
||||
let npmRoot = '';
|
||||
try {
|
||||
npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
const globalEntry = join(npmRoot, 'reasonix', 'dist', 'index.js');
|
||||
if (!existsSync(globalEntry)) {
|
||||
throw error;
|
||||
}
|
||||
return import(pathToFileURL(globalEntry).href);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await import('reasonix');
|
||||
const r = await importReasonix();
|
||||
CacheFirstLoop = r.CacheFirstLoop;
|
||||
DeepSeekClient = r.DeepSeekClient;
|
||||
ToolRegistry = r.ToolRegistry;
|
||||
|
||||
@@ -112,7 +112,8 @@ async function validateGateway(baseUrl) {
|
||||
assert.match(auth.headers.get("content-type") || "", /text\/html/);
|
||||
assert.equal(auth.headers.get("x-mnote-legacy-upstream"), null, "/auth 不应再代理 3100");
|
||||
assert.match(authText, /data-mnote-shell="auth"/);
|
||||
assert.match(authText, /邮箱登录/);
|
||||
assert.match(authText, /账号登录/);
|
||||
assert.match(authText, /邮箱或用户名/);
|
||||
assert.match(authText, /测试账号快速登录/);
|
||||
assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu);
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ async function main() {
|
||||
assert.equal(await staticHomeLinks.count(), 0, "根页仍使用 .mnote-home-links 静态设计列表作为主内容");
|
||||
const hasDocumentShell = (await documentShell.count()) > 0;
|
||||
const hasEmptyState = (await emptyState.count()) > 0;
|
||||
assert(hasDocumentShell || hasEmptyState, "根页必须渲染真实 active page shell 或明确空 workspace state");
|
||||
assert(!hasEmptyState, "空工作区不应再渲染可见占位空态");
|
||||
|
||||
const sidebarBox = await boundingBox(sidebar, "左侧栏");
|
||||
const topbarBox = await boundingBox(topbar, "顶栏");
|
||||
@@ -76,7 +76,7 @@ async function main() {
|
||||
const contentBox = await boundingBox(content, "主内容区");
|
||||
const primaryContentBox = hasDocumentShell
|
||||
? await boundingBox(documentShell, "active page shell")
|
||||
: await boundingBox(emptyState, "空 workspace state");
|
||||
: contentBox;
|
||||
const floatingAiBox = await boundingBox(floatingAi, "AI 浮动按钮");
|
||||
const floatingHelpBox = await boundingBox(floatingHelp, "帮助浮动按钮");
|
||||
|
||||
|
||||
@@ -65,8 +65,10 @@ async function validateAuthEntry(baseUrl) {
|
||||
assert.equal(auth.headers.get("x-mnote-legacy-upstream"), null, "/auth 不应代理到 3100");
|
||||
assert.match(authText, /data-mnote-shell="auth"/);
|
||||
assert.match(authText, /data-testid="mnote-auth-page"/);
|
||||
assert.match(authText, /邮箱登录/);
|
||||
assert.match(authText, /name="email"[^>]*type="email"|type="email"[^>]*name="email"/);
|
||||
assert.match(authText, /账号登录/);
|
||||
assert.match(authText, /邮箱或用户名/);
|
||||
assert.match(authText, /name="account"[^>]*type="text"|type="text"[^>]*name="account"/);
|
||||
assert.doesNotMatch(authText, /name="username"/);
|
||||
assert.match(authText, /name="password"[^>]*type="password"|type="password"[^>]*name="password"/);
|
||||
assert.match(authText, /data-auth-mode="convex-password"/);
|
||||
assert.match(authText, /没有账号?注册/);
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* task487 — Local Folder Tree Live Consumer smoke
|
||||
*
|
||||
* STATUS: 🔴 RED (预期失败)
|
||||
*
|
||||
* 本 smoke 验证 local_folder 文档页的 Tree Live Consumer 收敛目标:
|
||||
* 1. data-mnote-tree-live-transport 不再等于 "local-folder-static"(应改为
|
||||
* "local-folder-events" 或等价 tree live transport)
|
||||
* 2. 外部文件变化后,Sidebar / FileTree 刷新通过 tree live consumer 完成
|
||||
* (data-mnote-tree-live-applied 为 "snapshot" / "resync")
|
||||
* 3. data-mnote-local-folder-watch-applied 不再是唯一刷新证据
|
||||
*
|
||||
* 当前为 RED:Workers A(后端 local_folder tree live event stream)和
|
||||
* Worker B(前端 tree live controller 接入 local_folder)尚未完成。
|
||||
* 断言 1~3 当前均预期失败。——本 smoke 用于验收,直至三个断言全 PASS
|
||||
* 后收敛 checklist 才能归档。
|
||||
*/
|
||||
|
||||
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").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task487-local-folder-tree-live-consumer-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_BEFORE_PATH = path.join(OUTPUT_DIR, "page-tree-before-create.png");
|
||||
const SCREENSHOT_AFTER_PATH = path.join(OUTPUT_DIR, "page-tree-after-create.png");
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function treeUrl(root, mode) {
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("treeView", mode);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath, mode) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
if (mode) url.searchParams.set("treeView", mode);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
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}:task487`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 <html> 上的所有 data-mnote-tree-live-* 属性和
|
||||
* data-mnote-local-folder-watch-applied,返回一个状态对象。
|
||||
*/
|
||||
async function readLiveDomState(page) {
|
||||
return page.evaluate(() => {
|
||||
const html = document.documentElement;
|
||||
return {
|
||||
treeLiveTransport: html.getAttribute("data-mnote-tree-live-transport") || "",
|
||||
treeLiveStatus: html.getAttribute("data-mnote-tree-live-status") || "",
|
||||
treeLiveApplied: html.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
treeLiveRevision: html.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
treeLiveApplyError: html.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
localFolderWatchApplied: html.getAttribute("data-mnote-local-folder-watch-applied") || "",
|
||||
url: window.location.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言 helper:检查条件,返回 { pass, expected, actual, label }
|
||||
*/
|
||||
function check(label, condition, expected, actual) {
|
||||
return {
|
||||
label,
|
||||
pass: !!condition,
|
||||
expected: String(expected),
|
||||
actual: String(actual),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 page tree row 出现(基于 task435 模式)
|
||||
*/
|
||||
async function waitForPageTreeNode(page, documentId) {
|
||||
await page.waitForFunction(
|
||||
(expectedDocumentId) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
|
||||
.some((row) => row.getAttribute("data-node-id") === expectedDocumentId),
|
||||
documentId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-tree-live-consumer-"));
|
||||
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||
writeWorkspaceManifest(root, "user_real");
|
||||
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, "docs", "stable.md"), "# Stable Page\n", "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();
|
||||
const navigationEvents = [];
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
navigationEvents.push({ url: frame.url(), timestamp: Date.now() });
|
||||
}
|
||||
});
|
||||
|
||||
const assertions = [];
|
||||
let domStateBefore = null;
|
||||
let domStateAfterCreate = null;
|
||||
let domStateAfterDelete = null;
|
||||
let fatalError = null;
|
||||
|
||||
try {
|
||||
// ── 登录 ──
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLoginButton.count()) {
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
// ── Phase 1: Page Tree 视图 ──
|
||||
await page.goto(documentUrl(root, "README.md", "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
// 等待页面稳定
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const html = document.documentElement;
|
||||
const transport = html.getAttribute("data-mnote-tree-live-transport") || "";
|
||||
return transport !== "";
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(() => {});
|
||||
await page.waitForTimeout(300);
|
||||
navigationEvents.length = 0;
|
||||
|
||||
// 记录加载后 DOM 状态
|
||||
domStateBefore = await readLiveDomState(page);
|
||||
|
||||
// 断言 1: transport 不应为 local-folder-static
|
||||
assertions.push(check(
|
||||
"data-mnote-tree-live-transport !== 'local-folder-static'",
|
||||
domStateBefore.treeLiveTransport !== "local-folder-static",
|
||||
'非 local-folder-static(期望 "local-folder-events" 或等价)',
|
||||
domStateBefore.treeLiveTransport,
|
||||
));
|
||||
|
||||
// 断言 2: status 不应为 static
|
||||
assertions.push(check(
|
||||
"data-mnote-tree-live-status 不为 static",
|
||||
domStateBefore.treeLiveStatus !== "static",
|
||||
"connected / connecting / disabled 等非 static 值",
|
||||
domStateBefore.treeLiveStatus,
|
||||
));
|
||||
|
||||
// 断言 3: 有 transport 属性(不是空)
|
||||
assertions.push(check(
|
||||
"data-mnote-tree-live-transport 非空",
|
||||
domStateBefore.treeLiveTransport !== "",
|
||||
"非空字符串",
|
||||
domStateBefore.treeLiveTransport,
|
||||
));
|
||||
|
||||
// 截图:page tree 加载后状态
|
||||
await page.screenshot({ path: SCREENSHOT_BEFORE_PATH, fullPage: false });
|
||||
|
||||
// 断言 4: 外部文件创建后,tree live applied 应为 snapshot 或 resync
|
||||
// 先确认页面稳定
|
||||
await page.waitForTimeout(200);
|
||||
domStateBefore = await readLiveDomState(page);
|
||||
|
||||
// 创建外部文件
|
||||
const createdFileRel = "docs/live-consumer-test.md";
|
||||
fs.writeFileSync(path.join(root, createdFileRel), "# Live Consumer Test\n", "utf8");
|
||||
|
||||
// 等待 page tree row 出现(当前通过 polling 机制)
|
||||
await waitForPageTreeNode(page, localMdDocumentId(createdFileRel)).catch(() => {});
|
||||
|
||||
// 等待一小段时间让 tree live consumer 有机会触发
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
domStateAfterCreate = await readLiveDomState(page);
|
||||
|
||||
// 断言 4: applied 应为 snapshot 或 resync
|
||||
const appliedOk = domStateAfterCreate.treeLiveApplied === "snapshot"
|
||||
|| domStateAfterCreate.treeLiveApplied === "resync";
|
||||
assertions.push(check(
|
||||
"外部创建后 data-mnote-tree-live-applied 为 snapshot|resync",
|
||||
appliedOk,
|
||||
"snapshot 或 resync",
|
||||
domStateAfterCreate.treeLiveApplied,
|
||||
));
|
||||
|
||||
// 断言 5: watch-applied 不再是唯一证据
|
||||
// 即 treeLiveApplied 存在(非空)或 transport 不是 local-folder-static
|
||||
// 如果 watch-applied 是唯一标记而 treeLiveApplied 为空,则不合格
|
||||
const hasTreeLiveEvidence = domStateAfterCreate.treeLiveApplied !== ""
|
||||
|| domStateAfterCreate.treeLiveTransport !== "local-folder-static";
|
||||
assertions.push(check(
|
||||
"存在 tree live consumer 证据(非仅 local-folder-watch-applied)",
|
||||
hasTreeLiveEvidence,
|
||||
"treeLiveApplied 非空 或 transport !== local-folder-static",
|
||||
`treeLiveApplied="${domStateAfterCreate.treeLiveApplied}" transport="${domStateAfterCreate.treeLiveTransport}"`,
|
||||
));
|
||||
|
||||
// 断言 6: 外部文件删除后同样有 tree live applied 痕迹
|
||||
fs.rmSync(path.join(root, createdFileRel));
|
||||
await page.waitForFunction(
|
||||
(expectedId) => !Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]"))
|
||||
.some((row) => row.getAttribute("data-node-id") === expectedId),
|
||||
localMdDocumentId(createdFileRel),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(() => {});
|
||||
await page.waitForTimeout(400);
|
||||
domStateAfterDelete = await readLiveDomState(page);
|
||||
|
||||
const appliedDelOk = domStateAfterDelete.treeLiveApplied === "snapshot"
|
||||
|| domStateAfterDelete.treeLiveApplied === "resync";
|
||||
assertions.push(check(
|
||||
"外部删除后 data-mnote-tree-live-applied 为 snapshot|resync",
|
||||
appliedDelOk,
|
||||
"snapshot 或 resync",
|
||||
domStateAfterDelete.treeLiveApplied,
|
||||
));
|
||||
|
||||
// 截图:外部创建后
|
||||
await page.screenshot({ path: SCREENSHOT_AFTER_PATH, fullPage: false });
|
||||
|
||||
// ── Phase 2: FileTree 视图 ──
|
||||
await page.goto(documentUrl(root, "README.md", "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const html = document.documentElement;
|
||||
const transport = html.getAttribute("data-mnote-tree-live-transport") || "";
|
||||
return transport !== "";
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(() => {});
|
||||
await page.waitForTimeout(300);
|
||||
navigationEvents.length = 0;
|
||||
|
||||
const filetreeState = await readLiveDomState(page);
|
||||
|
||||
assertions.push(check(
|
||||
"FileTree: transport !== 'local-folder-static'",
|
||||
filetreeState.treeLiveTransport !== "local-folder-static",
|
||||
"非 local-folder-static",
|
||||
filetreeState.treeLiveTransport,
|
||||
));
|
||||
|
||||
assertions.push(check(
|
||||
"FileTree: status 不为 static",
|
||||
filetreeState.treeLiveStatus !== "static",
|
||||
"非 static",
|
||||
filetreeState.treeLiveStatus,
|
||||
));
|
||||
|
||||
// FileTree 外部创建
|
||||
fs.writeFileSync(path.join(root, "docs", "filetree-live-test.md"), "# FileTree Live\n", "utf8");
|
||||
// 等待 filetree row 出现(与 task435 同模式)
|
||||
await page.locator(`.tree-row[data-row-id="local:markdown:docs/filetree-live-test.md"]`).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
}).catch(() => {});
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const filetreeAfterCreate = await readLiveDomState(page);
|
||||
|
||||
assertions.push(check(
|
||||
"FileTree 外部创建后 tree-live-applied 为 snapshot|resync",
|
||||
filetreeAfterCreate.treeLiveApplied === "snapshot" || filetreeAfterCreate.treeLiveApplied === "resync",
|
||||
"snapshot 或 resync",
|
||||
filetreeAfterCreate.treeLiveApplied,
|
||||
));
|
||||
|
||||
} catch (err) {
|
||||
fatalError = err && err.stack ? err.stack : String(err);
|
||||
} finally {
|
||||
// 不论是否异常,都输出结果
|
||||
const allPassed = assertions.every((a) => a.pass);
|
||||
const result = {
|
||||
ok: allPassed,
|
||||
red: !allPassed,
|
||||
baseUrl: BASE_URL,
|
||||
outputDir: OUTPUT_DIR,
|
||||
summary: allPassed
|
||||
? "✅ 所有断言通过 — local_folder 已收敛到 tree live consumer"
|
||||
: "🔴 RED — 一个或多个断言失败,local_folder 仍使用 local-folder-static / polling 主链",
|
||||
assertions,
|
||||
domStateBefore,
|
||||
domStateAfterCreate,
|
||||
domStateAfterDelete,
|
||||
navigationEvents: navigationEvents.slice(0, 20),
|
||||
screenshots: {
|
||||
pageTreeLoaded: SCREENSHOT_BEFORE_PATH,
|
||||
afterExternalCreate: SCREENSHOT_AFTER_PATH,
|
||||
},
|
||||
fatalError,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(`task487 结果: ${RESULT_PATH}`);
|
||||
console.log(JSON.stringify({ ok: result.ok, red: result.red, assertionSummary: assertions.map((a) => ({
|
||||
label: a.label,
|
||||
pass: a.pass,
|
||||
actual: a.actual,
|
||||
})) }, null, 2));
|
||||
|
||||
await browser.close().catch(() => {});
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
|
||||
// 退出码:RED smoke 不 process.exit(1),让 CI 可收集失败证据
|
||||
if (!allPassed) {
|
||||
console.error("🔴 RED smoke — 预期失败(Workers A+B 尚未完成)");
|
||||
process.exit(0); // RED smoke 退出 0 以便 CI 管道记录而非阻断
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
const result = {
|
||||
ok: false,
|
||||
baseUrl: BASE_URL,
|
||||
error: error && error.stack ? error.stack : String(error),
|
||||
};
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.error(error);
|
||||
process.exit(0); // RED smoke 不阻断 CI
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "acp-multi-session-stability-smoke");
|
||||
const PROFILE = process.env.MNOTE_ACP_STABILITY_PROFILE || "reasonix";
|
||||
const RUNTIME = process.env.MNOTE_ACP_STABILITY_RUNTIME || "";
|
||||
const RUN_COUNT = Math.max(1, Number(process.env.MNOTE_ACP_STABILITY_RUN_COUNT || 3));
|
||||
const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_ACP_STABILITY_STREAM_TIMEOUT_MS || 60_000);
|
||||
const CANCEL_INDEX = Math.min(Math.max(0, Number(process.env.MNOTE_ACP_STABILITY_CANCEL_INDEX || 1)), RUN_COUNT - 1);
|
||||
|
||||
function nowSuffix() {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createHermesSession(request, target, suffix, index) {
|
||||
return requestJson(request, "/api/hermes/client/sessions", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "acp-smoke-user" },
|
||||
data: {
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
title: `ACP 稳定性 ${suffix} #${index + 1}`,
|
||||
profile: PROFILE,
|
||||
traceId: `trace_acp_session_${suffix}_${index}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createHermesRun(request, target, sessionId, suffix, index) {
|
||||
const payload = {
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
sessionId,
|
||||
profile: PROFILE,
|
||||
message:
|
||||
index === CANCEL_INDEX
|
||||
? `请用中文逐条列出 1 到 200 的检查项,每条都包含 ACP 多会话 smoke ${suffix},不要修改文件。`
|
||||
: `请用一句中文回复:ACP 多会话 smoke ${suffix} 第 ${index + 1} 条。不要修改文件。`,
|
||||
contextScope: "summary",
|
||||
traceId: `trace_acp_run_${suffix}_${index}`,
|
||||
actorId: "acp-smoke-user",
|
||||
};
|
||||
if (RUNTIME) {
|
||||
payload.acpRuntime = RUNTIME;
|
||||
}
|
||||
return requestJson(request, "/api/hermes/client/runs", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "acp-smoke-user" },
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function abortRun(request, runId) {
|
||||
return requestJson(request, `/api/hermes/client/runs/${encodeURIComponent(runId)}/abort`, {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": "acp-smoke-user" },
|
||||
data: { reason: "task488_acp_stability_smoke_cancel" },
|
||||
});
|
||||
}
|
||||
|
||||
function parseSseChunk(text, events) {
|
||||
for (const frame of text.split(/\n\n+/)) {
|
||||
const lines = frame.split(/\n/).filter(Boolean);
|
||||
if (lines.length === 0) continue;
|
||||
let event = "message";
|
||||
const dataLines = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice("event:".length).trim() || "message";
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
if (dataLines.length === 0) continue;
|
||||
const dataText = dataLines.join("\n");
|
||||
let data = dataText;
|
||||
try {
|
||||
data = JSON.parse(dataText);
|
||||
} catch {
|
||||
// 保留原始文本,便于排障。
|
||||
}
|
||||
events.push({ event, data });
|
||||
}
|
||||
}
|
||||
|
||||
async function cookieHeaderForContext(context) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
||||
}
|
||||
|
||||
async function streamRunEvents(runId, label, cookieHeader) {
|
||||
const events = [];
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/hermes/client/events/${encodeURIComponent(runId)}`, {
|
||||
headers: {
|
||||
"x-mnote-actor-id": "acp-smoke-user",
|
||||
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${label} events 请求失败: ${response.status} ${response.statusText} ${text.slice(0, 1200)}`);
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let raw = "";
|
||||
let pending = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
raw += chunk;
|
||||
pending += chunk;
|
||||
const frames = pending.split(/\n\n+/);
|
||||
pending = frames.pop() || "";
|
||||
if (frames.length > 0) {
|
||||
parseSseChunk(frames.join("\n\n"), events);
|
||||
}
|
||||
if (terminalEvent(events)) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const rest = decoder.decode();
|
||||
if (rest) {
|
||||
raw += rest;
|
||||
pending += rest;
|
||||
}
|
||||
if (pending.trim()) {
|
||||
parseSseChunk(pending, events);
|
||||
}
|
||||
if (!terminalEvent(events)) {
|
||||
throw new Error(`${label} events 未收到 terminal event`);
|
||||
}
|
||||
return { runId, status: response.status, events, raw };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
function terminalEvent(events) {
|
||||
return events.find((entry) => entry.event === "run.completed" || entry.event === "run.failed" || entry.event === "run.aborted");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = nowSuffix();
|
||||
const title = `TEST-ACP-STABILITY-${suffix}`;
|
||||
const createdIds = [];
|
||||
const evidence = {
|
||||
ok: false,
|
||||
baseUrl: BASE_URL,
|
||||
profile: PROFILE,
|
||||
runtime: RUNTIME || null,
|
||||
runCount: RUN_COUNT,
|
||||
cancelIndex: CANCEL_INDEX,
|
||||
title,
|
||||
sessions: [],
|
||||
runs: [],
|
||||
streams: [],
|
||||
};
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const target = await createTempDocument(context.request);
|
||||
createdIds.push(target.documentId);
|
||||
evidence.documentId = target.documentId;
|
||||
evidence.workspaceId = target.workspaceId;
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const sessions = [];
|
||||
for (let index = 0; index < RUN_COUNT; index += 1) {
|
||||
const session = await createHermesSession(context.request, target, suffix, index);
|
||||
assert(session.sessionId, `session ${index + 1} 缺少 sessionId`);
|
||||
sessions.push(session);
|
||||
evidence.sessions.push({
|
||||
index,
|
||||
sessionId: session.sessionId,
|
||||
persistence: session.persistence || null,
|
||||
profile: session.profile || PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
const runs = await Promise.all(
|
||||
sessions.map((session, index) => createHermesRun(context.request, target, session.sessionId, suffix, index)),
|
||||
);
|
||||
for (let index = 0; index < runs.length; index += 1) {
|
||||
assert(runs[index].runId, `run ${index + 1} 缺少 runId`);
|
||||
evidence.runs.push({
|
||||
index,
|
||||
runId: runs[index].runId,
|
||||
sessionId: runs[index].sessionId,
|
||||
persistence: runs[index].persistence || null,
|
||||
runtime: runs[index].runtime || null,
|
||||
});
|
||||
}
|
||||
|
||||
const cookieHeader = await cookieHeaderForContext(context);
|
||||
const streamPromises = runs.map((run, index) => streamRunEvents(run.runId, `run ${index + 1}`, cookieHeader));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const abortPayload = await abortRun(context.request, runs[CANCEL_INDEX].runId);
|
||||
evidence.abort = {
|
||||
runId: runs[CANCEL_INDEX].runId,
|
||||
status: abortPayload.status,
|
||||
ok: abortPayload.ok,
|
||||
events: abortPayload.events || [],
|
||||
};
|
||||
|
||||
const streamResults = await Promise.allSettled(streamPromises);
|
||||
const rejectedStreams = streamResults
|
||||
.map((result, index) => ({ result, index }))
|
||||
.filter(({ result }) => result.status === "rejected");
|
||||
if (rejectedStreams.length > 0) {
|
||||
evidence.streamErrors = rejectedStreams.map(({ result, index }) => ({
|
||||
index,
|
||||
runId: runs[index].runId,
|
||||
message: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
stack: result.reason instanceof Error ? result.reason.stack : null,
|
||||
}));
|
||||
throw new Error(`ACP SSE 读取失败: ${evidence.streamErrors.map((item) => `run ${item.index + 1}: ${item.message}`).join("; ")}`);
|
||||
}
|
||||
const streams = streamResults.map((result) => result.value);
|
||||
for (let index = 0; index < streams.length; index += 1) {
|
||||
const stream = streams[index];
|
||||
const terminal = terminalEvent(stream.events);
|
||||
evidence.streams.push({
|
||||
index,
|
||||
runId: stream.runId,
|
||||
eventCount: stream.events.length,
|
||||
eventNames: stream.events.map((entry) => entry.event),
|
||||
terminalEvent: terminal ? terminal.event : null,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(OUT_DIR, `events-${index + 1}-${stream.runId}.json`),
|
||||
JSON.stringify(stream.events, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
assert(terminal, `run ${index + 1} 必须收到 run.completed、run.failed 或 run.aborted`);
|
||||
if (index === CANCEL_INDEX) {
|
||||
assert.equal(terminal.event, "run.aborted", "被 abort 的 run 必须以 run.aborted 结束");
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(new Set(evidence.sessions.map((item) => item.sessionId)).size, RUN_COUNT, "sessionId 必须互相隔离");
|
||||
assert.equal(new Set(evidence.runs.map((item) => item.runId)).size, RUN_COUNT, "runId 必须互相隔离");
|
||||
assert(evidence.abort.ok === true, "abort API 必须返回 ok=true");
|
||||
evidence.ok = true;
|
||||
await fs.writeFile(path.join(OUT_DIR, "result.json"), JSON.stringify(evidence, null, 2), "utf8");
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true }).catch(() => undefined);
|
||||
await fs
|
||||
.writeFile(
|
||||
path.join(OUT_DIR, "error.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
)
|
||||
.catch(() => undefined);
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -204,10 +204,7 @@ async function registerTestAccountIfNeeded(page) {
|
||||
}
|
||||
|
||||
await switchButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('input[name="email"]').fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('input[name="username"]').fill(`${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('input[name="account"]').fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('input[name="password"]').fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
|
||||
await page.getByRole("button", { name: "注册" }).click({ timeout: UI_TIMEOUT_MS });
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user