feat(control-plane): add libSQL Turso backend
This commit is contained in:
@@ -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 中"脚本纪律"一节。
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 filter(source === '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 };
|
||||
|
||||
Reference in New Issue
Block a user