import { strict as assert } from "node:assert"; import { spawn } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli"); const visibleCommandPaths = [ "analysis age", "analysis burst", "analysis burst-detection detect", "analysis burst-location locate", "analysis contaminant", "analysis flushing", "analysis leakage identify", "analysis runs get", "analysis runs list", "analysis runs results", "analysis sensor-placement get", "analysis sensor-placement list", "analysis sensor-placement run", "analysis valve isolation", "component option get", "component option schema", "data scada get", "data scada list", "data scada schema", "data pipeline-health", "data timeseries analysis link-field", "data timeseries analysis node-field", "data timeseries analysis values", "data timeseries composite", "data timeseries realtime links", "data timeseries realtime nodes", "data timeseries realtime simulation-by-id-time", "data timeseries realtime simulation-by-time-property", "data timeseries scada query", "network get-all-pipes-properties", "network get-all-pumps-properties", "network get-all-reservoirs-properties", "network get-all-tanks-properties", "network get-all-valves-properties", "network get-junction-properties", "network get-pipe-properties", "network get-pump-properties", "network get-reservoir-properties", "network get-tank-properties", "network get-valve-properties", "simulation run", ]; const hiddenCommandPaths = []; function runCommand(command, args, input, options = {}) { return new Promise((resolveRun, reject) => { const child = spawn(command, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); child.on("error", reject); child.on("close", (exitCode) => resolveRun({ exitCode, stdout, stderr })); if (input !== undefined) child.stdin.end(JSON.stringify(input)); else child.stdin.end(); }); } function runCli(args, input) { return runCommand(cliPath, args, input); } function parseJsonResult(result) { return JSON.parse(result.stdout); } async function startJsonServer(responseData) { const seen = []; const server = createServer(async (req, res) => { const chunks = []; for await (const chunk of req) chunks.push(Buffer.from(chunk)); const text = Buffer.concat(chunks).toString("utf8"); seen.push({ body: text ? JSON.parse(text) : null, headers: req.headers, method: req.method, url: req.url, }); res.setHeader("content-type", "application/json"); res.end( JSON.stringify( typeof responseData === "function" ? responseData(req) : responseData, ), ); }); await new Promise((resolveListen, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolveListen); }); const address = server.address(); return { seen, url: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolveClose) => server.close(resolveClose)), }; } function normalizeSeenRequest(request) { const url = new URL(request.url, "http://127.0.0.1"); const query = {}; for (const key of [...new Set(url.searchParams.keys())].sort()) { const values = url.searchParams.getAll(key); query[key] = values.length === 1 ? values[0] : values; } return { body: request.body, headers: { authorization: request.headers.authorization, "x-project-id": request.headers["x-project-id"], }, method: request.method, path: url.pathname, query, }; } function defaultContractResponse(req) { const url = new URL(req.url, "http://127.0.0.1"); if (["/api/v1/pipes", "/api/v1/reservoirs", "/api/v1/tanks", "/api/v1/pumps", "/api/v1/valves", "/api/v1/scada-devices"].includes(url.pathname)) { return { items: [], limit: Number(url.searchParams.get("limit")), offset: Number(url.searchParams.get("offset")), total: 0, }; } return { accepted: true }; } async function runAgainstServer(name, runner, args, auth, responseData = defaultContractResponse) { const server = await startJsonServer(responseData); try { const result = await runner(["--auth-stdin", ...args], { ...auth, server: server.url }); if (!result.stdout.trim()) { throw new Error(`${name}: CLI produced empty stdout; exit=${result.exitCode}; stderr=${result.stderr}`); } return { exitCode: result.exitCode, payload: parseJsonResult(result), requests: server.seen.map(normalizeSeenRequest), stderr: result.stderr, }; } finally { await server.close(); } } test("emits structured JSON help compatible with tjwater-cli/v1", async () => { const result = await runCli(["help", "simulation", "run"]); assert.equal(result.exitCode, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.equal(payload.schema_version, "tjwater-cli/v1"); assert.equal(payload.command, "simulation run"); assert.equal(payload.usage, "tjwater-cli simulation run --start-time --duration "); }); test("discovers every visible command and keeps internal commands hidden", async () => { const rootResult = await runCli(["help"]); assert.equal(rootResult.exitCode, 0, rootResult.stderr); assert.deepEqual( parseJsonResult(rootResult).commands.map(({ command }) => command), ["analysis", "component", "data", "network", "simulation"], ); for (const command of visibleCommandPaths) { const result = await runCli(["help", ...command.split(" ")]); assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`); const payload = parseJsonResult(result); assert.equal(payload.ok, true, command); assert.equal(payload.command, command, command); assert.equal(payload.schema_version, "tjwater-cli/v1", command); assert.ok(payload.usage, `${command}: missing usage`); assert.ok(payload.examples.length > 0, `${command}: missing examples`); } for (const command of hiddenCommandPaths) { const result = await runCli(["help", ...command.split(" ")]); assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`); const payload = parseJsonResult(result); assert.equal(payload.ok, false, command); assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command); } }); test("sends auth headers and simulation body through the backend API contract", async () => { const server = await startJsonServer({ accepted: true }); try { const result = await runCli( [ "--auth-stdin", "simulation", "run", "--start-time", "2025-01-02T03:00:00+08:00", "--duration", "60", ], { server: server.url, access_token: "token-1", project_id: "project-1", headers: { "x-extra": "extra" }, }, ); assert.equal(result.exitCode, 0, result.stderr); assert.equal(server.seen[0].method, "POST"); assert.equal(server.seen[0].url, "/api/v1/simulation-runs"); assert.equal(server.seen[0].headers.authorization, "Bearer token-1"); assert.equal(server.seen[0].headers["x-extra"], "extra"); assert.deepEqual(server.seen[0].body, { start_time: "2025-01-02T03:00:00+08:00", duration: 60, }); const payload = JSON.parse(result.stdout); assert.equal(payload.ok, true); assert.match(payload.next_commands[0], /--end-time 2025-01-02T04:00:00\+08:00/); } finally { await server.close(); } }); test("get-all network commands collect every backend page", async () => { const items = Array.from({ length: 2_005 }, (_, index) => ({ id: `P${index + 1}`, node1: `N${index + 1}`, node2: `N${index + 2}`, })); const server = await startJsonServer((req) => { const url = new URL(req.url, "http://127.0.0.1"); const limit = Number(url.searchParams.get("limit") ?? 100); const offset = Number(url.searchParams.get("offset") ?? 0); return { items: items.slice(offset, offset + limit), limit, offset, total: items.length, }; }); try { const result = await runCli( ["--auth-stdin", "network", "get-all-pipes-properties"], { server: server.url, access_token: "token-1", project_id: "project-1", }, ); assert.equal(result.exitCode, 0, result.stderr); const payload = parseJsonResult(result); assert.equal(payload.data.length, items.length); assert.deepEqual(payload.data[0], items[0]); assert.deepEqual(payload.data.at(-1), items.at(-1)); assert.deepEqual( server.seen.map((request) => normalizeSeenRequest(request).query), [ { limit: "1000", offset: "0" }, { limit: "1000", offset: "1000" }, { limit: "1000", offset: "2000" }, ], ); } finally { await server.close(); } }); test("uses project scoped headers for realtime data commands", async () => { const server = await startJsonServer([{ id: "P1" }]); try { const result = await runCli( [ "--auth-stdin", "data", "timeseries", "realtime", "links", "--start-time", "2025-01-02T03:00:00+08:00", "--end-time", "2025-01-02T04:00:00+08:00", ], { server: server.url, accessToken: "token-2", projectId: "project-1", }, ); assert.equal(result.exitCode, 0, result.stderr); assert.equal(server.seen[0].method, "GET"); assert.equal( server.seen[0].url, "/api/v1/timeseries/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00", ); assert.equal(server.seen[0].headers.authorization, "Bearer token-2"); assert.equal(server.seen[0].headers["x-project-id"], "project-1"); } finally { await server.close(); } }); test("maps CLI pipe and junction types to backend link and node types", async () => { const server = await startJsonServer({ accepted: true }); const auth = { server: server.url, access_token: "token-3", project_id: "project-1" }; const at = "2025-01-02T03:30:00+08:00"; try { for (const args of [ ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at], ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"], ["data", "timeseries", "analysis", "values", "--run-id", "00000000-0000-0000-0000-000000000001", "--type", "pipe", "--time", at, "--field", "flow"], ]) { const result = await runCli(["--auth-stdin", ...args], auth); assert.equal(result.exitCode, 0, result.stderr); } const requests = server.seen.map(normalizeSeenRequest); assert.deepEqual( [requests[0].query.type, requests[1].query.type, requests[2].query.element_type], ["node", "link", "link"], ); } finally { await server.close(); } }); test("uses run-id based analysis, sensor placement, and SCADA contracts", async () => { const server = await startJsonServer(defaultContractResponse); const auth = { server: server.url, access_token: "token-4", project_id: "project-1" }; const runId = "00000000-0000-0000-0000-000000000001"; const start = "2025-01-02T03:00:00+08:00"; const end = "2025-01-02T04:00:00+08:00"; try { for (const args of [ ["analysis", "sensor-placement", "run", "--run-name", "placement-1", "--method", "kmeans", "--count", "5", "--min-diameter", "100"], ["analysis", "runs", "results", "--run-id", runId, "--result-type", "leakage_identification"], ["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end], ["data", "scada", "get", "--device-id", "SCADA-001"], ]) { const result = await runCli(["--auth-stdin", ...args], auth); assert.equal(result.exitCode, 0, result.stderr); } const requests = server.seen.map(normalizeSeenRequest); assert.deepEqual(requests[0], { body: { run_name: "placement-1", sensor_type: "pressure", method: "kmeans", sensor_count: 5, min_diameter: 100, }, headers: { authorization: "Bearer token-4", "x-project-id": "project-1" }, method: "POST", path: "/api/v1/sensor-placement-runs", query: {}, }); assert.equal(requests[1].path, `/api/v1/analysis/runs/${runId}/results`); assert.deepEqual(requests[1].query, { result_type: "leakage_identification" }); assert.equal(requests[2].path, `/api/v1/timeseries/analysis/runs/${runId}/nodes/J1`); assert.deepEqual(requests[2].query, { end_time: end, field: "pressure", start_time: start, }); assert.equal(requests[3].path, "/api/v1/scada-devices/detail"); assert.deepEqual(requests[3].query, { device_id: "SCADA-001" }); } finally { await server.close(); } }); test("does not expose removed scheme, risk, or valve-close commands", async () => { for (const command of [ "analysis leakage schemes list", "analysis risk network", "data scheme list", "data timeseries scheme simulation", ]) { const result = await runCli(["help", ...command.split(" ")]); assert.equal(result.exitCode, 0, result.stderr); const payload = parseJsonResult(result); assert.equal(payload.ok, false, command); assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command); } const valveClose = await runCli(["analysis", "valve", "--mode", "close"]); assert.equal(valveClose.exitCode, 2, valveClose.stderr); assert.equal(parseJsonResult(valveClose).error.code, "COMMAND_NOT_FOUND"); }); test("executes every command and key variant against the backend contract", async () => { const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-")); try { const burstFile = join(tempDir, "burst.json"); const valveFile = join(tempDir, "valve.json"); const pressureFile = join(tempDir, "pressure.json"); const flowFile = join(tempDir, "flow.json"); await writeFile(burstFile, JSON.stringify([{ id: "B1", size: 12.5 }])); await writeFile(valveFile, JSON.stringify([{ valve: "V1", opening: 0.5 }])); await writeFile(pressureFile, JSON.stringify({ burst_pressure: [1.1], normal_pressure: [2.2] })); await writeFile(flowFile, JSON.stringify({ burst_flow: [3.3], normal_flow: [4.4] })); const auth = { access_token: "token", network: "tjwater", project_id: "project-1", username: "alice", headers: { "x-extra": "extra" }, }; const start = "2025-01-02T03:00:00+08:00"; const end = "2025-01-02T04:00:00+08:00"; const at = "2025-01-02T03:30:00+08:00"; const runId = "00000000-0000-0000-0000-000000000001"; const cases = [ ["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]], ["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]], ["network get-all-pipes-properties", ["network", "get-all-pipes-properties"]], ["network get-reservoir-properties", ["network", "get-reservoir-properties", "--reservoir", "R1"]], ["network get-all-reservoirs-properties", ["network", "get-all-reservoirs-properties"]], ["network get-tank-properties", ["network", "get-tank-properties", "--tank", "T1"]], ["network get-all-tanks-properties", ["network", "get-all-tanks-properties"]], ["network get-pump-properties", ["network", "get-pump-properties", "--pump", "PU1"]], ["network get-all-pumps-properties", ["network", "get-all-pumps-properties"]], ["network get-valve-properties", ["network", "get-valve-properties", "--valve", "V1"]], ["network get-all-valves-properties", ["network", "get-all-valves-properties"]], ["component option schema", ["component", "option", "schema", "--kind", "time"]], ["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]], ["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]], ["analysis burst", ["analysis", "burst", "--start-time", start, "--duration", "900", "--burst-file", burstFile, "--scheme", "burst_case"]], ["analysis valve isolation", ["analysis", "valve", "isolation", "--element", "E1", "--disabled-valve", "V3"]], ["analysis flushing", ["analysis", "flushing", "--start-time", start, "--valve-setting-file", valveFile, "--drainage-node", "N1", "--flow", "100.5", "--duration", "900", "--scheme", "flush_case"]], ["analysis age", ["analysis", "age", "--start-time", start, "--duration", "900"]], ["analysis contaminant", ["analysis", "contaminant", "--start-time", start, "--duration", "900", "--source-node", "N1", "--concentration", "10.5", "--pattern", "P1", "--scheme", "contam_case"]], ["analysis sensor-placement run", ["analysis", "sensor-placement", "run", "--run-name", "place_case", "--method", "kmeans", "--count", "5", "--min-diameter", "100"]], ["analysis sensor-placement list", ["analysis", "sensor-placement", "list"]], ["analysis sensor-placement get", ["analysis", "sensor-placement", "get", "--run-id", runId]], ["analysis runs list", ["analysis", "runs", "list"]], ["analysis runs get", ["analysis", "runs", "get", "--run-id", runId]], ["analysis runs results", ["analysis", "runs", "results", "--run-id", runId, "--result-type", "leakage_identification"]], ["analysis leakage identify", ["analysis", "leakage", "identify", "--start-time", start, "--end-time", end, "--scheme", "leak_case"]], ["analysis burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "detect_case"]], ["analysis burst-location locate", ["analysis", "burst-location", "locate", "--start-time", start, "--end-time", end, "--burst-leakage", "50.5", "--scheme", "locate_case", "--data-source", "simulation", "--pressure-file", pressureFile, "--flow-file", flowFile, "--use-scada-flow"]], ["data realtime links", ["data", "timeseries", "realtime", "links", "--start-time", start, "--end-time", end]], ["data realtime nodes", ["data", "timeseries", "realtime", "nodes", "--start-time", start, "--end-time", end]], ["data realtime simulation-by-id-time", ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at]], ["data realtime simulation-by-time-property", ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"]], ["data analysis link-field", ["data", "timeseries", "analysis", "link-field", "--run-id", runId, "--link", "P1", "--field", "flow", "--start-time", start, "--end-time", end]], ["data analysis node-field", ["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end]], ["data analysis values", ["data", "timeseries", "analysis", "values", "--run-id", runId, "--type", "pipe", "--time", at, "--field", "flow"]], ["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]], ["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--run-id", runId]], ["data composite element-simulation", ["data", "timeseries", "composite", "--kind", "element-simulation", "--feature", "J1:pressure", "--start-time", start, "--end-time", end]], ["data composite element-scada", ["data", "timeseries", "composite", "--kind", "element-scada", "--feature", "J1", "--start-time", start, "--end-time", end, "--use-cleaned"]], ["data pipeline-health", ["data", "pipeline-health", "--time", end]], ["data scada get", ["data", "scada", "get", "--device-id", "SCADA-001"]], ["data scada list", ["data", "scada", "list"]], ["data scada schema", ["data", "scada", "schema"]], ]; for (const [name, args] of cases) { const run = await runAgainstServer(name, runCli, args, auth); assert.equal(run.exitCode, 0, `${name}: ${run.stderr}`); assert.equal(run.payload.ok, true, name); assert.equal(run.payload.schema_version, "tjwater-cli/v1", name); assert.ok(run.requests.length > 0, `${name}: no backend request`); for (const request of run.requests) { assert.match(request.path, /^\/api\/v1\//, name); assert.equal(request.headers.authorization, "Bearer token", name); assert.equal(request.headers["x-project-id"], "project-1", name); } } } finally { await rm(tempDir, { force: true, recursive: true }); } });