refactor(cli): split tjwater cli modules
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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 pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
|
||||
|
||||
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 runPythonCli(args, input) {
|
||||
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
|
||||
}
|
||||
|
||||
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(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"],
|
||||
"x-user-id": request.headers["x-user-id"],
|
||||
},
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
async function runAgainstServer(name, runner, args, auth, responseData = { accepted: true }) {
|
||||
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 <START_TIME> --duration <DURATION>");
|
||||
});
|
||||
|
||||
test("matches Python CLI help discovery and hidden command behavior", async () => {
|
||||
for (const args of [["help"], ["help", "analysis"]]) {
|
||||
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
||||
assert.equal(nodeResult.exitCode, pythonResult.exitCode);
|
||||
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult));
|
||||
}
|
||||
|
||||
const [nodeLeaf, pythonLeaf] = await Promise.all([
|
||||
runCli(["help", "simulation", "run"]),
|
||||
runPythonCli(["help", "simulation", "run"]),
|
||||
]);
|
||||
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode);
|
||||
const nodePayload = parseJsonResult(nodeLeaf);
|
||||
const pythonPayload = parseJsonResult(pythonLeaf);
|
||||
assert.equal(nodePayload.ok, pythonPayload.ok);
|
||||
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
|
||||
assert.equal(nodePayload.command, pythonPayload.command);
|
||||
assert.equal(nodePayload.summary, pythonPayload.summary);
|
||||
assert.equal(nodePayload.usage, pythonPayload.usage);
|
||||
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
|
||||
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
|
||||
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
|
||||
|
||||
const [nodeHidden, pythonHidden] = await Promise.all([
|
||||
runCli(["help", "analysis", "risk"]),
|
||||
runPythonCli(["help", "analysis", "risk"]),
|
||||
]);
|
||||
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
|
||||
const nodeError = parseJsonResult(nodeHidden);
|
||||
const pythonError = parseJsonResult(pythonHidden);
|
||||
delete nodeError.metadata.generated_at;
|
||||
delete pythonError.metadata.generated_at;
|
||||
assert.deepEqual(nodeError, pythonError);
|
||||
});
|
||||
|
||||
test("matches Python CLI leaf help for every visible command", async () => {
|
||||
const listResult = await runCommand(
|
||||
"python",
|
||||
[
|
||||
"-c",
|
||||
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
|
||||
],
|
||||
undefined,
|
||||
{ cwd: pythonCliCwd },
|
||||
);
|
||||
assert.equal(listResult.exitCode, 0, listResult.stderr);
|
||||
const commands = JSON.parse(listResult.stdout);
|
||||
|
||||
for (const command of commands) {
|
||||
const args = ["help", ...command.split(" ")];
|
||||
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
||||
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
|
||||
|
||||
const nodePayload = parseJsonResult(nodeResult);
|
||||
const pythonPayload = parseJsonResult(pythonResult);
|
||||
const comparable = (payload) => ({
|
||||
command: payload.command,
|
||||
summary: payload.summary,
|
||||
usage: payload.usage,
|
||||
examples: payload.examples,
|
||||
next_commands: payload.next_commands,
|
||||
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
|
||||
name,
|
||||
required,
|
||||
repeated,
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), 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",
|
||||
network: "tjwater",
|
||||
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/runsimulationmanuallybydate/");
|
||||
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, {
|
||||
name: "tjwater",
|
||||
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("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/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("matches Python CLI backend request shape for every command and key variants", 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",
|
||||
user_id: "user-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 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 close", ["analysis", "valve", "--mode", "close", "--start-time", start, "--valve", "V1", "--valve", "V2", "--duration", "900", "--scheme", "valve_case"]],
|
||||
["analysis valve isolation", ["analysis", "valve", "--mode", "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 kmeans", ["analysis", "sensor-placement", "kmeans", "--count", "5", "--min-diameter", "100", "--scheme", "place_case"]],
|
||||
["analysis leakage identify", ["analysis", "leakage", "identify", "--start-time", start, "--end-time", end, "--scheme", "leak_case"]],
|
||||
["analysis leakage schemes list", ["analysis", "leakage", "schemes", "list"]],
|
||||
["analysis leakage schemes get", ["analysis", "leakage", "schemes", "get", "leak_case"]],
|
||||
["analysis burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "detect_case"]],
|
||||
["analysis burst-detection schemes list", ["analysis", "burst-detection", "schemes", "list"]],
|
||||
["analysis burst-detection schemes get", ["analysis", "burst-detection", "schemes", "get", "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"]],
|
||||
["analysis burst-location schemes list", ["analysis", "burst-location", "schemes", "list"]],
|
||||
["analysis burst-location schemes get", ["analysis", "burst-location", "schemes", "get", "locate_case"]],
|
||||
["analysis risk pipe-now", ["analysis", "risk", "pipe-now", "--pipe", "P1"]],
|
||||
["analysis risk pipe-history", ["analysis", "risk", "pipe-history", "--pipe", "P1"]],
|
||||
["analysis risk network", ["analysis", "risk", "network"]],
|
||||
["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 scheme links", ["data", "timeseries", "scheme", "links", "--start-time", start, "--end-time", end, "--scheme", "scheme_case", "--scheme-type", "simulation"]],
|
||||
["data scheme node-field", ["data", "timeseries", "scheme", "node-field", "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
|
||||
["data scheme simulation by-id", ["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "J1", "--time", at, "--type", "junction", "--scheme", "scheme_case"]],
|
||||
["data scheme simulation by-property", ["data", "timeseries", "scheme", "simulation", "--query", "by-scheme-time-property", "--time", at, "--type", "pipe", "--property", "flow", "--scheme", "scheme_case"]],
|
||||
["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, "--scheme", "scheme_case"]],
|
||||
["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 composite pipeline-health", ["data", "timeseries", "composite", "pipeline-health", "--pipe", "P1", "--start-time", start, "--end-time", end]],
|
||||
["data scada get", ["data", "scada", "get", "--kind", "info", "--id", "SCADA-001"]],
|
||||
["data scada list", ["data", "scada", "list", "--kind", "info"]],
|
||||
["data scheme schema", ["data", "scheme", "schema"]],
|
||||
["data scheme get", ["data", "scheme", "get", "--name", "scheme_case"]],
|
||||
["data scheme list", ["data", "scheme", "list"]],
|
||||
];
|
||||
|
||||
for (const [name, args] of cases) {
|
||||
const [nodeRun, pythonRun] = await Promise.all([
|
||||
runAgainstServer(`${name} node`, runCli, args, auth),
|
||||
runAgainstServer(`${name} python`, runPythonCli, args, auth),
|
||||
]);
|
||||
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`);
|
||||
assert.deepEqual(nodeRun.requests, pythonRun.requests, name);
|
||||
}
|
||||
} finally {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user