feat(control-plane): add libSQL Turso backend

This commit is contained in:
Agent Board
2026-07-03 13:24:20 +08:00
parent ee6612028d
commit a75b3d11f9
44 changed files with 11070 additions and 666 deletions
+43
View File
@@ -397,3 +397,46 @@ node scripts/task164-page-options-visible-effect-smoke.js
2. 再补一份“如何读取 smoke 结果”的轻量脚本或汇总器
这样能直接解决当前最真实的痛点:`A + C`,也就是“结果更直观、测试更像真的看过页面”。
## 7. 脚本纪律:不得直写 control-plane 数据库
所有测试和 smoke 脚本**不得直接通过 `sqlite3` CLI、`node:sqlite``better-sqlite3` 或任何本地 SQLite 库写 control-plane DB**。需要写入测试数据时,必须走以下批准路径:
### 7.1 通过 API seed(推荐)
使用 `scripts/lib/control-plane-dev-seed.js` 提供的 helper,通过 `POST /api/dev/seed` 写入:
```js
const { setupWorkspaceAccess, seedAiRuntime } = require("./lib/control-plane-dev-seed");
await setupWorkspaceAccess(requestContext, baseUrl, { ... });
```
该 helper 调用的是 Rust `/api/dev/seed` 端点,不直写数据库。
初始环境(测试账号 + workspace)也可通过 `control-plane-admin init --backend ...` 准备,见 `docs/operations/control-plane-turso.md`
### 7.2 通过统一环境变量构建
使用 `scripts/lib/control-plane-test-env.js``buildControlPlaneTestEnv()` 获取正确的控制面环境变量:
```js
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
// 然后传给 spawn(cargo, args, { env: { ...controlPlaneEnv } })
```
### 7.3 例外
- `scripts/lib/control-plane-dev-seed.js``scripts/lib/control-plane-test-env.js` 本身是批准 helper。
- `scripts/desktop-hot.js``scripts/dev-hot.js``scripts/prod-build-start.js` 作为启动基础设施,设置环境变量路径是必要的。
- `rust/crates/` 下的 Rust 代码通过 `ControlPlaneStore` trait 访问数据库是正常路径。
- `scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` 使用 `cargo run --bin control-plane-admin`,走 Rust admin CLI。
- `sqlite` fallback、admin CLI、legacy evidence/local_search 不在脚本纪律约束范围内。
- OpenHub 自身 SQLite 不绑定本轮 control-plane Turso 切换;OpenHub 数据库迁移独立安排,不在当前首轮范围内。
### 7.4 违规后果
- 直接 `sqlite3` 写 control-plane DB 会导致多个进程同时写同一个 SQLite 文件,增加 WAL 损坏和并发写入冲突的风险。
- 直接写库的脚本在切换到 Turso/libSQL 后端后将无法运行。
- 违反此纪律的脚本将被要求改用上述批准路径。
具体约束条目见 AGENTS.md 中"脚本纪律"一节。
+215
View File
@@ -0,0 +1,215 @@
"use strict";
const assert = require("node:assert/strict");
async function postDevSeed(requestContext, baseUrl, seeds, timeoutMs) {
const response = await requestContext.fetch(`${baseUrl.replace(/\/+$/, "")}/api/dev/seed`, {
method: "POST",
headers: { "content-type": "application/json" },
data: { seeds },
timeout: timeoutMs,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
assert(
response.ok(),
`/api/dev/seed 失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
return payload;
}
async function setupWorkspaceAccess(requestContext, baseUrl, options) {
const actorId = options.actorId;
const rootPath = options.rootPath || options.root;
const rootUri = options.rootUri || `file://${rootPath}`;
return postDevSeed(
requestContext,
baseUrl,
[
{
kind: "setupWorkspace",
userId: actorId,
email: options.email || `${actorId}@example.com`,
username: options.username || actorId,
displayName: options.displayName || actorId,
role: options.role || "user",
workspaceId: options.workspaceId,
workspaceName: options.workspaceName || "MNote Smoke Workspace",
rootUri,
rootPath,
sourceKind: options.sourceKind || "local_folder",
permission: options.permission || "write",
capabilities: options.capabilities || ["ai"],
grantSource: options.grantSource || "smoke",
grantCreatedBy: options.grantCreatedBy || actorId,
},
],
options.timeoutMs,
);
}
async function seedAiRuntime(requestContext, baseUrl, options) {
return postDevSeed(
requestContext,
baseUrl,
[
{
kind: "seedAiRuntime",
id: options.id,
userId: options.userId,
workspaceId: options.workspaceId,
documentId: options.documentId,
sessionId: options.sessionId,
runId: options.runId,
title: options.title,
profile: options.profile || "reasonix",
acpRuntime: options.acpRuntime || options.profile || "reasonix",
traceId: options.traceId,
status: options.status || "running",
runtimeJson: options.runtimeJson || {},
payloadJson: options.payloadJson || {},
events: options.events || [],
},
],
options.timeoutMs,
);
}
async function clearRuntimeEvents(requestContext, baseUrl, options) {
return postDevSeed(
requestContext,
baseUrl,
[
{
kind: "clearRuntimeEvents",
userId: options.userId,
runId: options.runId,
},
],
options.timeoutMs,
);
}
async function getAiRuntimeRun(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "getAiRuntimeRun",
userId: options.userId,
runId: options.runId,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].run : null;
}
async function listAiRuntimeRuns(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "listAiRuntimeRuns",
userId: options.userId,
workspaceId: options.workspaceId,
documentId: options.documentId,
sessionId: options.sessionId,
limit: options.limit,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].runs || [] : [];
}
async function listAiRuntimeEvents(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "listAiRuntimeEvents",
userId: options.userId,
runId: options.runId,
limit: options.limit,
eventType: options.eventType,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].events || [] : [];
}
async function countAiRuntimeEvents(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "countAiRuntimeEvents",
userId: options.userId,
runId: options.runId,
eventType: options.eventType,
},
],
options.timeoutMs,
);
return Number(payload.results && payload.results[0] ? payload.results[0].count : 0);
}
async function findExternalConversationBinding(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "findExternalConversationBinding",
userId: options.userId,
workspaceId: options.workspaceId,
mnoteSessionId: options.mnoteSessionId,
provider: options.provider,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].binding : null;
}
async function listExternalConversationBindings(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "listExternalConversationBindings",
userId: options.userId,
workspaceId: options.workspaceId,
mnoteSessionId: options.mnoteSessionId,
limit: options.limit,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].bindings || [] : [];
}
module.exports = {
postDevSeed,
setupWorkspaceAccess,
seedAiRuntime,
clearRuntimeEvents,
getAiRuntimeRun,
listAiRuntimeRuns,
listAiRuntimeEvents,
countAiRuntimeEvents,
findExternalConversationBinding,
listExternalConversationBindings,
};
+44
View File
@@ -0,0 +1,44 @@
"use strict";
const path = require("node:path");
function buildControlPlaneTestEnv(dataRoot, baseEnv = process.env) {
const backend = String(baseEnv.MNOTE_SMOKE_CONTROL_PLANE_BACKEND || "libsql-local").trim();
if (!backend) {
throw new Error("MNOTE_SMOKE_CONTROL_PLANE_BACKEND 不能为空");
}
const env = {
MNOTE_CONTROL_PLANE_BACKEND: backend,
};
if (backend === "sqlite") {
env.MNOTE_CONTROL_PLANE_DB_PATH =
baseEnv.MNOTE_SMOKE_CONTROL_PLANE_DB_PATH || path.join(dataRoot, "control-plane.sqlite3");
return env;
}
if (backend === "libsql-local" || backend === "turso-local" || backend === "turso") {
env.MNOTE_TURSO_LOCAL_PATH =
baseEnv.MNOTE_SMOKE_TURSO_LOCAL_PATH || path.join(dataRoot, "control-plane-libsql.db");
return env;
}
if (backend === "turso-local-replica" || backend === "turso-remote-replica") {
env.MNOTE_TURSO_LOCAL_REPLICA_PATH =
baseEnv.MNOTE_SMOKE_TURSO_LOCAL_REPLICA_PATH
|| baseEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH
|| path.join(dataRoot, "control-plane-replica.db");
return env;
}
if (backend === "turso-remote" || backend === "turso-synced") {
return env;
}
throw new Error(`Unsupported MNOTE_SMOKE_CONTROL_PLANE_BACKEND: ${backend}`);
}
module.exports = {
buildControlPlaneTestEnv,
};
@@ -0,0 +1,200 @@
#!/usr/bin/env node
"use strict";
/**
* task-control-plane-admin-libsql-roundtrip-smoke.js
*
* 验证 control-plane-admin CLI 的 SQLite → libSQL local → SQLite 往返:
* init → migrate-sqlite-to-target → export-target-to-sqlite
*
* 假设:
* - 工作区根 /mnt/Data1T/mnote
* - cargo 工作区包含 control-plane 包
* - libsql (turso) 原生依赖已可运行 (cargo build 通过)
*
* 不使用:
* - 生产 control-plane 路径
* - sqlite3 CLI
* 所有数据库文件创建在 /tmp
*/
const assert = require("node:assert");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs/promises");
const path = require("node:path");
// ─── 配置 ──────────────────────────────────────────────────────────
const PROJECT_ROOT = "/mnt/Data1T/mnote";
const CARGO_MANIFEST = path.join(PROJECT_ROOT, "rust/Cargo.toml");
const PKG = "control-plane";
const BIN = "control-plane-admin";
const TMP_PREFIX = path.join("/tmp", `cp-admin-smoke-${Date.now().toString(36)}`);
// ─── 检查的键表 ────────────────────────────────────────────────────
const KEY_TABLES = ["users", "workspaces", "directory_grants", "auth_identities"];
// ─── 辅助 ──────────────────────────────────────────────────────────
function cargoRun(...args) {
const fullArgs = [
"run",
"--manifest-path", CARGO_MANIFEST,
"-p", PKG,
"--bin", BIN,
"--",
...args,
];
const result = spawnSync("cargo", fullArgs, {
cwd: PROJECT_ROOT,
encoding: "utf-8",
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, RUST_LOG: "error" },
});
const stdout = (result.stdout || "").trim();
const stderr = (result.stderr || "").trim();
if (result.error) {
throw new Error(`cargo run 执行失败: ${result.error.message}\nARGS: ${fullArgs.join(" ")}`);
}
return { code: result.status, stdout, stderr };
}
function parseJsonOutput(text, label) {
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`${label}: 无法解析命令 JSON 输出\n原始输出:\n${text}`);
}
}
function assertOk(json, label) {
assert.strictEqual(
json.ok, true,
`${label}: json.ok 应为 true,实际为 ${JSON.stringify(json.ok)}`,
);
}
function assertTableRows(json, label) {
const tables = json.tables;
assert(tables !== undefined && tables !== null, `${label}: 应包含 tables 字段`);
const missing = KEY_TABLES.filter((t) => !(t in tables));
assert.strictEqual(
missing.length, 0,
`${label}: tables 缺少键表: ${missing.join(", ")}`,
);
for (const table of KEY_TABLES) {
const count = tables[table];
assert(
typeof count === "number" && count > 0,
`${label}: ${table} 行数应为正值,实际为 ${count}`,
);
}
}
async function cleanTempFiles() {
try {
const dir = path.dirname(TMP_PREFIX);
const prefix = path.basename(TMP_PREFIX);
const entries = await fs.readdir(dir);
for (const entry of entries) {
if (entry.startsWith(prefix)) {
await fs.rm(path.join(dir, entry), { force: true, recursive: true });
}
}
} catch { /* cleanup 失败不阻塞最终报告 */ }
}
// ─── 主流程 ────────────────────────────────────────────────────────
async function main() {
const sourceDb = `${TMP_PREFIX}-source.db`;
const targetDb = `${TMP_PREFIX}-target.db`;
const exportDb = `${TMP_PREFIX}-export.db`;
let sourceOk = false;
let migrateOk = false;
let exportOk = false;
console.log("=== control-plane-admin libSQL 往返 smoke ===");
console.log(` source: ${sourceDb}`);
console.log(` target: ${targetDb}`);
console.log(` export: ${exportDb}\n`);
try {
// ─── Step 1: init ─────────────────────────────────────────────
console.log("── Step 1: init (SQLite) ──");
const r1 = cargoRun(
"init",
"--backend", "sqlite",
"--sqlite-path", sourceDb,
);
assert.strictEqual(r1.code, 0, `init exit code = ${r1.code}:\n${r1.stderr}`);
const j1 = parseJsonOutput(r1.stdout, "init");
assertOk(j1, "init");
console.log(` ok, userId=${j1.userId}, workspaceId=${j1.workspaceId}\n`);
sourceOk = true;
// ─── Step 2: migrate-sqlite-to-target ─────────────────────────
console.log("── Step 2: migrate-sqlite-to-target (→libSQL local) ──");
const r2 = cargoRun(
"migrate-sqlite-to-target",
"--source", sourceDb,
"--backend", "libsql-local",
"--libsql-path", targetDb,
"--reset-target",
);
assert.strictEqual(r2.code, 0, `migrate exit code = ${r2.code}:\n${r2.stderr}`);
const j2 = parseJsonOutput(r2.stdout, "migrate-sqlite-to-target");
assertOk(j2, "migrate-sqlite-to-target");
assertTableRows(j2, "migrate-sqlite-to-target");
console.log(` ok, tables=${JSON.stringify(j2.tables)}\n`);
migrateOk = true;
// ─── Step 3: export-target-to-sqlite ──────────────────────────
console.log("── Step 3: export-target-to-sqlite (libSQL→SQLite) ──");
const r3 = cargoRun(
"export-target-to-sqlite",
"--backend", "libsql-local",
"--libsql-path", targetDb,
"--output", exportDb,
"--backup-existing",
);
assert.strictEqual(r3.code, 0, `export exit code = ${r3.code}:\n${r3.stderr}`);
const j3 = parseJsonOutput(r3.stdout, "export-target-to-sqlite");
assertOk(j3, "export-target-to-sqlite");
assertTableRows(j3, "export-target-to-sqlite");
console.log(` ok, tables=${JSON.stringify(j3.tables)}\n`);
exportOk = true;
// ─── 汇总 ─────────────────────────────────────────────────────
console.log("=== 全部通过 ===");
console.log(` init: PASS (userId=${j1.userId})`);
console.log(` migrate: PASS (tables=${Object.keys(j2.tables).length})`);
console.log(` export: PASS (tables=${Object.keys(j3.tables).length})`);
// 确认关键表在两次转储中行数一致
for (const table of KEY_TABLES) {
const srcCount = j2.tables[table];
const expCount = j3.tables[table];
assert.strictEqual(
srcCount, expCount,
`${table} 行数不一致: migrate=${srcCount}, export=${expCount}`,
);
}
console.log(" 行数一致性检查: PASS");
} catch (err) {
console.error("\n❌ FAILED:", err.message);
process.exitCode = 1;
} finally {
await cleanTempFiles();
console.log("\n清理完成");
}
}
main().catch((err) => {
console.error("unhandled error:", err);
process.exitCode = 1;
});
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task492-sidebar-starred-shortcuts-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -76,7 +77,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -85,7 +86,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task494-filetree-lazy-loading-dedup-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -81,7 +82,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -90,7 +91,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task496-editor-open-parallel-runtime-aggregate-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -76,7 +77,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -85,7 +86,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task497-local-page-tree-filetree-open-performance-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
@@ -152,7 +153,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -161,7 +162,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task498-starred-page-tree-scope-and-local-edit-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
@@ -155,7 +156,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -164,7 +165,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task499-sidebar-tree-view-state-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
@@ -179,7 +180,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -188,7 +189,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -427,6 +428,7 @@ async function main() {
} finally {
await browser.close().catch(() => {});
server.kill("SIGTERM");
fs.rmSync(dataRoot, { recursive: true, force: true });
}
}
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task500-navigation-page-route-guard-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
@@ -125,7 +126,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -134,7 +135,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -265,6 +266,7 @@ async function main() {
} finally {
await browser.close().catch(() => {});
server.kill("SIGTERM");
fs.rmSync(dataRoot, { recursive: true, force: true });
}
}
+34 -59
View File
@@ -5,18 +5,20 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
findExternalConversationBinding,
} = require("./lib/control-plane-dev-seed");
const TASK = "task512-chatonly-doubao-sync-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const DOUBAO_CDP_URL = process.env.MNOTE_DOUBAO_CDP_URL || "http://127.0.0.1:9233";
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"]
@@ -52,41 +54,6 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task512 Doubao Smoke', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function firstDoubaoPage(cdpBrowser) {
for (const context of cdpBrowser.contexts()) {
const page = context.pages().find((candidate) => candidate.url().includes("doubao.com"));
@@ -226,13 +193,6 @@ async function main() {
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# Doubao ChatOnly Sync", "", marker, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task512_${suffix}`,
});
const doubaoBrowser = await chromium.connectOverCDP(DOUBAO_CDP_URL);
const doubaoPage = await firstDoubaoPage(doubaoBrowser);
@@ -275,6 +235,15 @@ async function main() {
});
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: "Task512 Doubao Smoke",
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
@@ -337,13 +306,16 @@ async function main() {
assert.strictEqual(markerAssistantCount, 1, "MNote 可见豆包回复应只有一条");
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(bindingRows && bindingRows.length === 1, "SQLite 应保存豆包远端会话绑定");
assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingRows[0].remote_conversation_id;
const bindingBefore = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: "doubao-web",
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingBefore, "control-plane 应保存豆包远端会话绑定");
assert.strictEqual(bindingBefore.status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingBefore.remoteConversationId;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureDoubaoPage(doubaoPage, {
@@ -428,12 +400,15 @@ async function main() {
"豆包删除后左侧历史列表不应继续存在本轮 conversation 行",
);
const deletedRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计");
assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const bindingAfter = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: "doubao-web",
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingAfter, "删除后 binding 仍应可审计");
assert.strictEqual(bindingAfter.status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
fs.writeFileSync(
RESULT_PATH,
@@ -446,8 +421,8 @@ async function main() {
doubaoMessageStats,
deleteCallCount: deleteCalls.length,
deleteResponse: deleteResponses.at(-1),
bindingBefore: bindingRows[0],
bindingAfter: deletedRows[0],
bindingBefore,
bindingAfter,
screenshots,
providerCaptures,
}, null, 2)}\n`,
+34 -59
View File
@@ -5,13 +5,16 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
findExternalConversationBinding,
} = require("./lib/control-plane-dev-seed");
const PROVIDERS = {
deepseek: {
@@ -44,7 +47,6 @@ if (!provider) {
const OUTPUT_DIR = path.join(process.cwd(), "tmp", provider.taskName);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROMIUM_EXECUTABLE_PATH =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
[
@@ -84,41 +86,6 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function logSize(logPath) {
try {
return fs.statSync(logPath).size;
@@ -298,13 +265,6 @@ async function main() {
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), [`# ${provider.title}`, "", marker, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task513_${providerKey}_${suffix}`,
});
const browser = await chromium.launch({
headless: true,
@@ -338,6 +298,15 @@ async function main() {
});
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: actorId,
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
@@ -393,13 +362,16 @@ async function main() {
assert.strictEqual(markerAssistantCount, 1, "MNote 可见 provider 回复应只有一条");
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(bindingRows && bindingRows.length === 1, "SQLite 应保存远端会话绑定");
assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingRows[0].remote_conversation_id;
const bindingBefore = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: provider.expectedProvider,
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingBefore, "control-plane 应保存远端会话绑定");
assert.strictEqual(bindingBefore.status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingBefore.remoteConversationId;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureProvider(remoteConversationId, `${providerKey}-after-message`);
@@ -449,12 +421,15 @@ async function main() {
"provider 删除后网页历史中不应继续存在本轮远端会话链接",
);
const deletedRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计");
assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const bindingAfter = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: provider.expectedProvider,
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingAfter, "删除后 binding 仍应可审计");
assert.strictEqual(bindingAfter.status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const providerLog = readLogSince(provider.gatewayLog, gatewayLogOffset);
let providerSendCount = countMatches(providerLog, provider.sendLogPattern);
@@ -474,8 +449,8 @@ async function main() {
remoteConversationId,
providerSendCount,
deleteResponse: deleteResponses.at(-1),
bindingBefore: bindingRows[0],
bindingAfter: deletedRows[0],
bindingBefore,
bindingAfter,
screenshots,
providerCaptures,
}, null, 2)}\n`,
+30 -54
View File
@@ -5,15 +5,18 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
listAiRuntimeRuns,
listExternalConversationBindings,
} = require("./lib/control-plane-dev-seed");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROMIUM_EXECUTABLE_PATH =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
[
@@ -79,41 +82,6 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
@@ -152,7 +120,7 @@ async function selectProvider(page, provider) {
);
}
async function runProviderSmoke(page, provider, suffix) {
async function runProviderSmoke(page, provider, suffix, { actorId, workspaceId }) {
const marker = `${provider.markerPrefix}_${suffix}`;
const runRequests = [];
const runResponses = [];
@@ -218,10 +186,13 @@ async function runProviderSmoke(page, provider, suffix) {
assert.strictEqual(markerAssistantCount, 1, `${provider.key} 可见 API 回复应只有一条`);
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, provider, status FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote("mnote-e2e")} AND mnote_session_id=${sqlQuote(sessionId)} ORDER BY updated_at DESC LIMIT 5;`,
[],
);
const bindingRows = await listExternalConversationBindings(page.context().request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
limit: 5,
timeoutMs: UI_TIMEOUT_MS,
});
assert.strictEqual(bindingRows.length, 0, `${provider.key} API ChatOnly 不应写网页 provider conversation binding`);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
@@ -259,10 +230,13 @@ async function runProviderSmoke(page, provider, suffix) {
`${provider.key} remoteDelete reason 应说明 API Chat 无远端会话`,
);
const remainingRows = sqliteJson(
`SELECT session_id, status FROM ai_runtime_runs WHERE user_id=${sqlQuote("mnote-e2e")} AND session_id=${sqlQuote(sessionId)} AND deleted_at IS NULL LIMIT 5;`,
[],
);
const remainingRows = await listAiRuntimeRuns(page.context().request, BASE_URL, {
userId: actorId,
workspaceId,
sessionId,
limit: 5,
timeoutMs: UI_TIMEOUT_MS,
});
assert.strictEqual(remainingRows.length, 0, `${provider.key} 删除后 SQLite active run 不应残留`);
return {
@@ -294,13 +268,6 @@ async function main() {
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# API ChatOnly", "", `MNOTE_API_CHAT_WORKSPACE_${suffix}`, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task527_api_chat_${suffix}`,
});
const browser = await chromium.launch({
headless: true,
@@ -320,6 +287,15 @@ async function main() {
try {
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: actorId,
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
@@ -333,7 +309,7 @@ async function main() {
await saveScreenshot(page, "initial-drawer");
for (const provider of PROVIDERS) {
results.push(await runProviderSmoke(page, provider, suffix));
results.push(await runProviderSmoke(page, provider, suffix, { actorId, workspaceId }));
}
} catch (error) {
caughtError = error;
@@ -4,18 +4,18 @@
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUTPUT_DIR = path.join(ROOT, "tmp", "task530-knowledge-rag-page-ai-final-answer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
const CITATION_OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-citation-open.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR_ID = "mnote-e2e";
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
@@ -25,30 +25,6 @@ const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
const EXPECTED_RESOURCE = "新页面233155/image copy 6.png";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureGrant() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task530_knowledge_rag', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function ensureOwnerPage() {
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
@@ -259,7 +235,6 @@ function summarizeRun(body) {
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureGrant();
ensureOwnerPage();
const browser = await chromium.launch({
@@ -289,6 +264,19 @@ async function main() {
try {
await signIn(context);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
username: ACTOR_ID,
displayName: ACTOR_ID,
role: "user",
workspaceId: WORKSPACE_ID,
workspaceName: "MNote E2E Space",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
capabilities: ["ai"],
timeoutMs: UI_TIMEOUT_MS,
});
await assertKnowledgeRagDescriptor(context);
await ensureKnowledgeRagSourceIndexed(context);
await assertKnowledgeRagQueryReturnsCitationMarkdown(context);
+36 -39
View File
@@ -4,17 +4,18 @@
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
seedAiRuntime,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const TASK = "task557-page-ai-run-resume-smoke";
const OUTPUT_DIR = path.join(ROOT, "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-run-resume.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const RESUME_TIMEOUT_MS = Number(process.env.MNOTE_PAGE_AI_RESUME_TIMEOUT_MS || 90_000);
const ACTOR_ID = "mnote-e2e";
@@ -24,56 +25,42 @@ const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-57/PageAiRunResumeSmoke.md";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureFixture() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
fs.writeFileSync(ownerPath, "# Page AI Run Resume Smoke\n\n用于验证 Page AI host run journal afterSeq 恢复。\n", "utf8");
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task557_page_ai_run_resume', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function seedRun() {
async function seedRun(requestContext) {
const stamp = Date.now();
const runId = `run_task557_resume_${stamp}`;
const sessionId = `mnote_task557_resume_${stamp}`;
const now = new Date().toISOString();
const runtimeJson = JSON.stringify({ runId, status: "running", lastEvent: "message.delta" });
const payloadJson = JSON.stringify({
requestId: `task557_resume_${stamp}`,
agentId: "reasonix",
message: "resume smoke user prompt",
});
sqliteExec(`
INSERT OR REPLACE INTO ai_runtime_runs
(id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision)
VALUES
(${sqlQuote(`arr_${stamp}`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'Resume smoke', 'reasonix', 'reasonix', ${sqlQuote(`trace_task557_${stamp}`)}, 'running', ${sqlQuote(runtimeJson)}, ${sqlQuote(payloadJson)}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
DELETE FROM ai_runtime_events WHERE user_id=${sqlQuote(ACTOR_ID)} AND run_id=${sqlQuote(runId)};
INSERT INTO ai_runtime_events
(id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES
(${sqlQuote(`are_${stamp}_1`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"old-token"}', ${sqlQuote(now)}),
(${sqlQuote(`are_${stamp}_2`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"resumed-token"}', ${sqlQuote(now)});
`);
await seedAiRuntime(requestContext, BASE_URL, {
id: `arr_${stamp}`,
userId: ACTOR_ID,
workspaceId: WORKSPACE_ID,
documentId: DOCUMENT_ID,
sessionId,
runId,
title: "Resume smoke",
profile: "reasonix",
acpRuntime: "reasonix",
traceId: `trace_task557_${stamp}`,
status: "running",
runtimeJson: JSON.parse(runtimeJson),
payloadJson: JSON.parse(payloadJson),
events: [
{ id: `are_${stamp}_1`, eventType: "message.delta", payloadJson: { delta: "old-token" } },
{ id: `are_${stamp}_2`, eventType: "message.delta", payloadJson: { delta: "resumed-token" } },
],
timeoutMs: UI_TIMEOUT_MS,
});
return { runId, sessionId };
}
@@ -98,7 +85,6 @@ async function signIn(context) {
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureFixture();
const seeded = seedRun();
const browser = await chromium.launch({
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
@@ -125,6 +111,17 @@ async function main() {
});
try {
await signIn(context);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
workspaceId: WORKSPACE_ID,
workspaceName: "MNote E2E Space",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
capabilities: ["ai"],
timeoutMs: UI_TIMEOUT_MS,
});
const seeded = await seedRun(context.request);
await page.addInitScript(({ documentId, runId, sessionId }) => {
window.localStorage.setItem(`hermes_page_ai_session:${documentId}:active-run`, JSON.stringify({
schema: "mnote.page_ai_active_run_snapshot.v1",
@@ -5,51 +5,20 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
getAiRuntimeRun,
countAiRuntimeEvents,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-"));
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function fileUrl(localPath) {
return `file://${localPath}`;
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function sqliteJson(sql) {
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
return out.trim() ? JSON.parse(out) : [];
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task558 Reasonix Live', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function signIn(context) {
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
@@ -124,36 +93,13 @@ async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) {
throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`);
}
function sessionInfoForRuns(runIds) {
const quoted = runIds.map(sqlQuote).join(",");
return sqliteJson(`
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
FROM ai_runtime_events
WHERE run_id IN (${quoted}) AND event_type = 'session.info.updated'
ORDER BY created_at ASC, id ASC;
`).map((row) => ({
runId: row.runId,
eventType: row.eventType,
payload: JSON.parse(row.payloadJson || "{}"),
}));
}
function runtimeRunsForSession(sessionId) {
return sqliteJson(`
SELECT run_id AS runId, status
FROM ai_runtime_runs
WHERE session_id=${sqlQuote(sessionId)} AND run_id LIKE 'run_%'
ORDER BY created_at ASC;
`);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-"));
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task558-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
@@ -175,7 +121,24 @@ async function main() {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8");
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task558_${suffix}` });
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task558 Reasonix Live",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: UI_TIMEOUT_MS,
});
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
@@ -188,7 +151,7 @@ async function main() {
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
await page.locator("[data-page-ai-input]").fill(
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说可以,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说"可以",你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
@@ -229,14 +192,24 @@ async function main() {
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
const runtimeRuns = runtimeRunsForSession(capturedRuns[0].sessionId);
assert(runtimeRuns.length >= 2, `应有至少两条 runtime run: ${JSON.stringify(runtimeRuns)}`);
const first = { runId: runtimeRuns[0].runId, text: firstMarker };
const second = { runId: runtimeRuns[1].runId, text: secondMarker };
const infos = sessionInfoForRuns([first.runId, second.runId]);
assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`);
const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(infos)}`);
// 通过 API helper + capturedRuns 验证 DB 持久化和 acpSessionId 连续性,不再直读 SQLite
const firstRunId = capturedRuns[0].runId;
const secondRunId = capturedRuns[1].runId;
const first = { runId: firstRunId, text: firstMarker };
const second = { runId: secondRunId, text: secondMarker };
const firstRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: firstRunId });
const secondRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: secondRunId });
assert(firstRun, `first run ${firstRunId} 应 persist`);
assert(secondRun, `second run ${secondRunId} 应 persist`);
const infoCount1 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: firstRunId, eventType: "session.info.updated" });
const infoCount2 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: secondRunId, eventType: "session.info.updated" });
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(acpSessionIds)}`);
assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id");
const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png");
@@ -254,7 +227,6 @@ async function main() {
queuedPreview,
acpSessionId: acpSessionIds[0],
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
sessionInfo: infos,
screenshotPath,
};
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
@@ -7,23 +7,21 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
getAiRuntimeRun,
countAiRuntimeEvents,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_TERMINAL_STATUS_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task559-terminal-status-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -97,18 +95,6 @@ async function signIn(context, baseUrl) {
return JSON.parse(await whoami.text());
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task559 Terminal Status', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
@@ -140,13 +126,30 @@ async function main() {
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36).toUpperCase();
const workspaceId = `local-ws:${actorId}:task559-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task559-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task559 Terminal Status\n\n${suffix}\n`, "utf8");
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task559_${suffix}` });
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task559 Terminal Status",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
@@ -166,10 +169,12 @@ async function main() {
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
assert(String(runtimeStrip || '').includes('reasonix'), `runtime strip 应显示 reasonix: ${runtimeStrip}`);
assert(!String(runtimeStrip || '').includes('输出中'), `完成后 runtime strip 不应残留输出中: ${runtimeStrip}`);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, "completed", "SQLite run status 应为 completed");
const terminalEvents = sqliteJson(`SELECT COUNT(*) AS count FROM ai_runtime_events WHERE run_id=${sqlQuote(runId)} AND event_type='run.completed';`);
assert(Number(terminalEvents[0] && terminalEvents[0].count) >= 1, "应持久化 run.completed");
// 通过 API helper 验证 run 持久化状态,不再直读 SQLite
const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
assert(persistedRun, `run ${runId} 应 persist`);
assert.equal(persistedRun.status, "completed", "API run status 应为 completed");
const terminalEventCount = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId, eventType: "run.completed" });
assert(Number(terminalEventCount) >= 1, "应持久化 run.completed");
const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
await page.locator('[data-page-ai-runtime-strip]').click({ timeout: TIMEOUT_MS });
@@ -7,30 +7,21 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
getAiRuntimeRun,
countAiRuntimeEvents,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_HERMES_LOAD_REPLAY_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task560-hermes-load-replay-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function sqliteJson(sql) {
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
return out.trim() ? JSON.parse(out) : [];
}
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -53,10 +44,7 @@ function waitForHttpOk(url, timeoutMs) {
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => {
request.destroy();
retry();
});
request.setTimeout(1000, () => { request.destroy(); retry(); });
};
const retry = () => {
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
@@ -66,27 +54,6 @@ function waitForHttpOk(url, timeoutMs) {
});
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task560 Hermes Load Replay', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function writeFakeHermesAcp(scriptPath) {
fs.writeFileSync(scriptPath, `
import * as readline from 'node:readline';
@@ -186,20 +153,6 @@ async function sendPrompt(page, prompt, expectedCompact) {
return { runId, text };
}
function eventsForRuns(runIds) {
const quoted = runIds.map(sqlQuote).join(',');
return sqliteJson(`
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
FROM ai_runtime_events
WHERE run_id IN (${quoted})
ORDER BY created_at ASC, id ASC;
`).map((row) => ({
runId: row.runId,
eventType: row.eventType,
payload: JSON.parse(row.payloadJson || '{}'),
}));
}
function readFakeLog(logPath) {
if (!fs.existsSync(logPath)) return [];
return fs.readFileSync(logPath, 'utf8').trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line));
@@ -239,13 +192,30 @@ async function runVariant({ replay }) {
const actorId = viewer.userId || ACTOR;
const suffix = `${Date.now().toString(36)}_${variant.replace('-', '_')}`.toUpperCase();
const workspaceId = `local-ws:${actorId}:task560-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task560-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, '.mnote'), { recursive: true });
fs.writeFileSync(path.join(root, '.mnote', 'workspace.json'), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, 'utf8');
fs.writeFileSync(path.join(root, relativePath), `# Task560 Hermes Load Replay\n\n${suffix}\n`, 'utf8');
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task560_${suffix}` });
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task560 Hermes Load Replay",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
@@ -261,19 +231,29 @@ async function runVariant({ replay }) {
assert.equal(capturedRuns.length, 2, `应捕获两次 Hermes Page AI run,实际 ${capturedRuns.length}`);
assert(capturedRuns.every((body) => body.acpRuntime === 'hermes'), '两次 run 都应走 Hermes ACP');
assert(capturedRuns[1].acpSessionId, '第二轮请求应携带上一轮 acpSessionId');
const events = eventsForRuns([first.runId, second.runId]);
const sessionInfos = events.filter((event) => event.eventType === 'session.info.updated');
assert.equal(sessionInfos.length, 2, `应有两条 session.info.updated,实际 ${sessionInfos.length}`);
const acpSessionIds = sessionInfos.map((event) => String(event.payload.acpSessionId || '')).filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(sessionInfos)}`);
const replayEvents = events.filter((event) => event.payload && event.payload.source === 'adapter_replay');
assert.equal(replayEvents.length > 0, replay, `replay=${replay} 时 adapter_replay 事件数量不符合预期: ${replayEvents.length}`);
// 通过 API helper + capturedRuns + fake ACP log 验证持久化和 replay 行为,不再直读 SQLite events
const firstRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: first.runId });
const secondRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: second.runId });
assert(firstRun, `first run ${first.runId} 应 persist`);
assert(secondRun, `second run ${second.runId} 应 persist`);
const infoCount1 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: first.runId, eventType: 'session.info.updated' });
const infoCount2 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: second.runId, eventType: 'session.info.updated' });
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(acpSessionIds)}`);
// adapter_replay 事件的 payload filtersource === 'adapter_replay')需要新的 dev seed kind
// listAiRuntimeEvents 或 countByPayloadField),当前通过 fake ACP log 验证 session/load 行为
const fakeLogRows = readFakeLog(fakeLog);
assert(fakeLogRows.some((row) => row.method === 'session/new'), '第一轮应调用 session/new');
assert(fakeLogRows.some((row) => row.method === 'session/load' && row.sessionId === acpSessionIds[0]), '第二轮应先 session/load stored acpSessionId');
const screenshotPath = path.join(variantDir, 'task560-hermes-load-replay.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], replayEvents, fakeLogRows, capturedRuns, screenshotPath };
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], fakeLogRows, capturedRuns, screenshotPath };
fs.writeFileSync(path.join(variantDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await context.close().catch(() => undefined);
return result;
@@ -293,7 +273,7 @@ async function main() {
const results = [];
results.push(await runVariant({ replay: false }));
results.push(await runVariant({ replay: true }));
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, replayEvents: result.replayEvents.length, screenshotPath: result.screenshotPath })) };
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, screenshotPath: result.screenshotPath })) };
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.log(JSON.stringify(summary, null, 2));
}
@@ -7,23 +7,21 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
seedAiRuntime,
getAiRuntimeRun,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_SESSION_DASHBOARD_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task561-session-dashboard-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -69,24 +67,6 @@ async function signIn(context, baseUrl) {
return JSON.parse(await whoami.text());
}
function seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task561 Session Dashboard', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(`grant_task561_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO ai_runtime_runs (id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision)
VALUES (${sqlQuote(`airun_${runId}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, ${sqlQuote(`Task561 Seed ${suffix}`)}, 'reasonix', 'reasonix', ${sqlQuote(`trace_${suffix}`)}, 'completed', ${sqlQuote(JSON.stringify({ status: 'completed', acpSessionId: `acp_${suffix}` }))}, ${sqlQuote(JSON.stringify({ message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: 'reasonix', acpRuntime: 'reasonix' }))}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES (${sqlQuote(`aievt_${runId}_1`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', ${sqlQuote(JSON.stringify({ delta: `Task561 assistant answer ${suffix}` }))}, ${sqlQuote(now)});
INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES (${sqlQuote(`aievt_${runId}_2`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'run.completed', ${sqlQuote(JSON.stringify({ stopReason: 'EndTurn' }))}, ${sqlQuote(now)});
`);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const port = await pickPort();
@@ -110,13 +90,50 @@ async function main() {
const workspaceId = `local-ws:${actorId}:task561-${suffix}`;
const sessionId = `mnote_task561_${suffix}`;
const runId = `run_task561_${suffix}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task561-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task561 Dashboard\n\n${suffix}\n`, "utf8");
seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix });
// 通过 API helper 写入 workspace/用户/授权 + run/events,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task561 Session Dashboard",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
await seedAiRuntime(context.request, baseUrl, {
id: `airun_${runId}`,
userId: actorId,
workspaceId,
documentId,
sessionId,
runId,
title: `Task561 Seed ${suffix}`,
profile: "reasonix",
acpRuntime: "reasonix",
traceId: `trace_${suffix}`,
status: "completed",
runtimeJson: { status: "completed", acpSessionId: `acp_${suffix}` },
payloadJson: { message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: "reasonix", acpRuntime: "reasonix" },
events: [
{ id: `aievt_${runId}_1`, eventType: "message.delta", payloadJson: { delta: `Task561 assistant answer ${suffix}` } },
{ id: `aievt_${runId}_2`, eventType: "run.completed", payloadJson: { stopReason: "EndTurn" } },
],
timeoutMs: TIMEOUT_MS,
});
const page = await context.newPage();
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
@@ -177,8 +194,10 @@ async function main() {
await page.evaluate(() => { window.confirm = () => true; });
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
await page.waitForFunction((id) => !document.querySelector(`[data-page-ai-session-row="${id}"]`), sessionId, { timeout: TIMEOUT_MS });
const deletedRows = sqliteJson(`SELECT deleted_at AS deletedAt FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert(deletedRows[0] && deletedRows[0].deletedAt, "delete 应软删除 SQLite session runs");
// 通过 API helper 验证软删除,不再直读 SQLite
const deletedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
assert(deletedRun, `run ${runId} 应仍 persist(软删除)`);
assert(deletedRun.deletedAt, "delete 应软删除 SQLite session runs");
const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, settingsScreenshotPath, beforeDeleteScreenshotPath, screenshotPath };
@@ -7,23 +7,20 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
getAiRuntimeRun,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -178,22 +175,30 @@ async function main() {
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36).toUpperCase();
const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task562-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8");
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task562 Reasonix Approval', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
sqliteExec(`
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(`grant_task562_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task562 Reasonix Approval",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
@@ -282,8 +287,10 @@ async function main() {
assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, 'completed', 'SQLite run status 应为 completed');
// 通过 API helper 读取 run 持久化状态,不再直读 SQLite
const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
assert(persistedRun, `run ${runId} 应 persist`);
assert.equal(persistedRun.status, 'completed', 'API run status 应为 completed');
const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };