test(cli): add e2e test for nerve sense query #165
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* E2E: `nerve sense query` against a real daemon + persisted `_signals` (issue #156).
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type TestDaemonHandle,
|
||||
pollUntil,
|
||||
runCli,
|
||||
startTestDaemon,
|
||||
stopTestDaemon,
|
||||
} from "./e2e-harness.js";
|
||||
|
||||
describe("e2e sense query", () => {
|
||||
let daemon: TestDaemonHandle | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
const h = daemon;
|
||||
daemon = null;
|
||||
if (h === null) return;
|
||||
await Promise.race([
|
||||
stopTestDaemon(h),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
async function waitForSignalsPersisted(handle: TestDaemonHandle): Promise<void> {
|
||||
const dbPath = join(handle.nerveRoot, "data", "senses", "counter.db");
|
||||
await pollUntil(() => {
|
||||
if (!existsSync(dbPath)) return false;
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as
|
||||
| { cnt: number }
|
||||
| undefined;
|
||||
|
|
||||
db.close();
|
||||
return row !== undefined && row.cnt > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
/** Start daemon, trigger counter, wait until `_signals` has a row. */
|
||||
async function startDaemonWithPersistedSignal(): Promise<TestDaemonHandle> {
|
||||
const handle = await startTestDaemon();
|
||||
const triggerResult = await runCli(handle, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
expect(triggerResult.stdout).toContain("Triggered");
|
||||
await waitForSignalsPersisted(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
it(
|
||||
"after trigger, persisted _signals and sense query counter returns at least one row",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
expect(queryResult.stdout).not.toContain("(0 rows)");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"default sense query output lists payload column and counter count in payload",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
expect(queryResult.stdout).toContain("payload");
|
||||
expect(queryResult.stdout).toMatch(/count/);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"nerve sense query counter --json prints a JSON array with payload on each row",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const jsonResult = await runCli(daemon, ["sense", "query", "counter", "--json"]);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
const rows = JSON.parse(jsonResult.stdout.trim()) as unknown;
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const row of rows as Record<string, unknown>[]) {
|
||||
expect(Object.keys(row)).toContain("payload");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"nerve sense query counter --sql runs custom read-only SQL and prints total column",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const sqlResult = await runCli(daemon, [
|
||||
"sense",
|
||||
"query",
|
||||
"counter",
|
||||
"--sql",
|
||||
"SELECT count(*) as total FROM _signals",
|
||||
]);
|
||||
expect(sqlResult.exitCode).toBe(0);
|
||||
expect(sqlResult.stdout).toContain("total");
|
||||
expect(sqlResult.stdout).not.toContain("(0 rows)");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -105,6 +105,28 @@ describe("parseSenseQueryArgs", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
|
||||
});
|
||||
|
||||
it("uses --sql value instead of positional SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT 2"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT 2",
|
||||
});
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql=SELECT 3"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT 3",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers --sql over trailing positional SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT a", "SELECT b"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT a",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when --sql has no value", () => {
|
||||
expect(() => parseSenseQueryArgs(["cpu", "--sql"])).toThrow(/Missing value for --sql/);
|
||||
});
|
||||
|
||||
it("throws when name is missing", () => {
|
||||
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ const senseQueryCommand = defineCommand({
|
||||
meta: {
|
||||
name: "query",
|
||||
description:
|
||||
"Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name; multiple words are joined.",
|
||||
'Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name, or use --sql "…".',
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
|
||||
@@ -56,26 +56,67 @@ export function defaultPreviewSql(table: string): string {
|
||||
return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`;
|
||||
}
|
||||
|
||||
function endIndexAfterGenericDashArg(rawArgs: string[], i: number): number {
|
||||
const a = rawArgs[i];
|
||||
const eq = a.indexOf("=");
|
||||
if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
|
||||
return i + 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
type SenseQueryArgStep = {
|
||||
nextIndex: number;
|
||||
flagSql: string | undefined;
|
||||
positionalToken: string | null;
|
||||
};
|
||||
|
||||
function nextSenseQueryArgStep(rawArgs: string[], i: number): SenseQueryArgStep {
|
||||
const a = rawArgs[i];
|
||||
if (a === "--json" || a === "--no-json") {
|
||||
return { nextIndex: i, flagSql: undefined, positionalToken: null };
|
||||
}
|
||||
if (a.startsWith("--sql=")) {
|
||||
return { nextIndex: i, flagSql: a.slice("--sql=".length), positionalToken: null };
|
||||
}
|
||||
if (a === "--sql") {
|
||||
if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) {
|
||||
throw new Error("Missing value for --sql");
|
||||
}
|
||||
return { nextIndex: i + 1, flagSql: rawArgs[i + 1], positionalToken: null };
|
||||
}
|
||||
if (a.startsWith("-")) {
|
||||
return {
|
||||
nextIndex: endIndexAfterGenericDashArg(rawArgs, i),
|
||||
flagSql: undefined,
|
||||
positionalToken: null,
|
||||
};
|
||||
}
|
||||
return { nextIndex: i, flagSql: undefined, positionalToken: a };
|
||||
}
|
||||
|
||||
/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */
|
||||
export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } {
|
||||
let flagSql: string | undefined;
|
||||
const pos: string[] = [];
|
||||
for (let i = 0; i < rawArgs.length; i++) {
|
||||
const a = rawArgs[i];
|
||||
if (a === "--json" || a === "--no-json") continue;
|
||||
if (a.startsWith("-")) {
|
||||
const eq = a.indexOf("=");
|
||||
if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
let i = 0;
|
||||
while (i < rawArgs.length) {
|
||||
const step = nextSenseQueryArgStep(rawArgs, i);
|
||||
if (step.flagSql !== undefined) {
|
||||
flagSql = step.flagSql;
|
||||
}
|
||||
pos.push(a);
|
||||
if (step.positionalToken !== null) {
|
||||
pos.push(step.positionalToken);
|
||||
}
|
||||
i = step.nextIndex + 1;
|
||||
}
|
||||
if (pos.length < 1) {
|
||||
throw new Error("Missing sense name");
|
||||
}
|
||||
const name = pos[0];
|
||||
const sql = pos.length > 1 ? pos.slice(1).join(" ") : undefined;
|
||||
const positionalSql = pos.length > 1 ? pos.slice(1).join(" ") : undefined;
|
||||
const trimmedFlag = flagSql?.trim();
|
||||
const sql = trimmedFlag !== undefined && trimmedFlag.length > 0 ? trimmedFlag : positionalSql;
|
||||
return { name, sql };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
💡 这里 db.close() 如果 prepare/get 抛异常就不会执行。建议 try/finally 包一下。虽然是测试代码且外层 catch 了,但 DB handle 泄漏在反复 poll 时可能累积。