From be8ee3cc2ed3884cd904d07bb6c59e3ae32c2e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 27 Apr 2026 06:37:29 +0000 Subject: [PATCH] test(cli): add e2e test for nerve sense query Verifies sense query returns data after signal persistence: - Default query returns rows (not 0 rows) - Output contains payload columns - --json flag returns valid JSON array with payload field - --sql flag runs custom read-only SQL Also adds --sql flag support to sense query CLI. Fixes #156 --- .../cli/src/__tests__/e2e-sense-query.test.ts | 121 ++++++++++++++++++ .../cli/src/__tests__/sense-sqlite.test.ts | 22 ++++ packages/cli/src/commands/sense.ts | 2 +- packages/cli/src/sense-sqlite.ts | 63 +++++++-- 4 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/__tests__/e2e-sense-query.test.ts diff --git a/packages/cli/src/__tests__/e2e-sense-query.test.ts b/packages/cli/src/__tests__/e2e-sense-query.test.ts new file mode 100644 index 0000000..0e4f29e --- /dev/null +++ b/packages/cli/src/__tests__/e2e-sense-query.test.ts @@ -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((_, reject) => + setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000), + ), + ]); + }); + + async function waitForSignalsPersisted(handle: TestDaemonHandle): Promise { + 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 { + 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[]) { + 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)"); + }, + ); +}); diff --git a/packages/cli/src/__tests__/sense-sqlite.test.ts b/packages/cli/src/__tests__/sense-sqlite.test.ts index d195852..4825b9b 100644 --- a/packages/cli/src/__tests__/sense-sqlite.test.ts +++ b/packages/cli/src/__tests__/sense-sqlite.test.ts @@ -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/); }); diff --git a/packages/cli/src/commands/sense.ts b/packages/cli/src/commands/sense.ts index 9ffbade..4797726 100644 --- a/packages/cli/src/commands/sense.ts +++ b/packages/cli/src/commands/sense.ts @@ -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: { diff --git a/packages/cli/src/sense-sqlite.ts b/packages/cli/src/sense-sqlite.ts index fc52530..3e09afd 100644 --- a/packages/cli/src/sense-sqlite.ts +++ b/packages/cli/src/sense-sqlite.ts @@ -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 }; } -- 2.43.0