201 lines
7.1 KiB
JavaScript
201 lines
7.1 KiB
JavaScript
#!/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;
|
||
|
|
});
|