import { test } from "node:test"; import assert from "node:assert"; import lib from "../tailscalectl/lib.js"; const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, PendingAction, commandForPendingAction } = lib; /* * Unit tests for pure functions in lib.js. * * TailscaleWidget.qml has no automated test coverage. Proc.runCommand * coordination, busy-mutex behavior, and widget UI must be verified * manually in a running DMS instance. */ // --- parsePeers --- test("parsePeers extracts exitNode from ExitNodeOption", () => { const peerMap = { "peer-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNodeOption: true }, "peer-2": { HostName: "laptop", TailscaleIPs: ["100.64.0.2"], Online: true, ExitNodeOption: false } }; const peers = parsePeers(peerMap); assert.strictEqual(peers.length, 2); assert.strictEqual(peers[0].exitNode, true); assert.strictEqual(peers[1].exitNode, false); assert.strictEqual(peers[0].hostname, "router"); assert.strictEqual(peers[0].ip, "100.64.0.1"); assert.strictEqual(peers[0].online, true); }); test("parsePeers returns empty array for null/undefined peerMap", () => { assert.deepStrictEqual(parsePeers(null), []); assert.deepStrictEqual(parsePeers(undefined), []); }); test("parsePeers returns empty array for empty peerMap", () => { assert.deepStrictEqual(parsePeers({}), []); }); test("parsePeers falls back to key when HostName missing", () => { const peers = parsePeers({ "node-key-abc": { TailscaleIPs: ["100.64.0.9"], Online: false } }); assert.strictEqual(peers[0].hostname, "node-key-abc"); assert.strictEqual(peers[0].ip, "100.64.0.9"); assert.strictEqual(peers[0].online, false); assert.strictEqual(peers[0].exitNode, false); }); test("parsePeers uses empty ip when TailscaleIPs missing or empty", () => { const peers = parsePeers({ a: { HostName: "a" }, b: { HostName: "b", TailscaleIPs: [] } }); assert.strictEqual(peers[0].ip, ""); assert.strictEqual(peers[1].ip, ""); }); // --- makeExitNodeCommand / hostname validation --- test("makeExitNodeCommand returns tailscale set command for hostname", () => { const cmd = makeExitNodeCommand("router"); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]); }); test("makeExitNodeCommand with empty string clears exit node", () => { const cmd = makeExitNodeCommand(""); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]); }); test("makeExitNodeCommand returns null for invalid hostname", () => { assert.strictEqual(makeExitNodeCommand("; rm"), null); assert.strictEqual(makeExitNodeCommand("$(whoami)"), null); assert.strictEqual(makeExitNodeCommand(null), null); assert.strictEqual(makeExitNodeCommand(42), null); }); test("makeExitNodeCommand still produces correct argv for valid input", () => { assert.deepStrictEqual(makeExitNodeCommand(""), ["tailscale", "set", "--exit-node="]); assert.deepStrictEqual(makeExitNodeCommand("gluetun-sjc"), ["tailscale", "set", "--exit-node=gluetun-sjc"]); }); test("isValidExitNodeHostname accepts empty string (clear)", () => { assert.strictEqual(isValidExitNodeHostname(""), true); }); test("isValidExitNodeHostname accepts realistic Tailscale hostnames", () => { ["router", "gluetun-sjc", "my-exit-node-01", "peer_with_underscore", "a.b.c"].forEach((h) => { assert.strictEqual(isValidExitNodeHostname(h), true, h); }); }); test("isValidExitNodeHostname accepts MagicDNS-style FQDNs under 253 chars", () => { assert.strictEqual(isValidExitNodeHostname("my-node.tail1234.ts.net"), true); const longButValid = "a".repeat(60) + "." + "b".repeat(60) + "." + "c".repeat(60); assert.ok(longButValid.length < 253); assert.strictEqual(isValidExitNodeHostname(longButValid), true); }); test("isValidExitNodeHostname rejects injection attempts and garbage", () => { ["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)", " spacy ", "-leading", "trailing-"].forEach((h) => { assert.strictEqual(isValidExitNodeHostname(h), false, h); }); }); test("isValidExitNodeHostname rejects non-strings and oversized names", () => { assert.strictEqual(isValidExitNodeHostname(undefined), false); assert.strictEqual(isValidExitNodeHostname({}), false); assert.strictEqual(isValidExitNodeHostname("x".repeat(254)), false); }); // --- findActiveExitNode --- test("findActiveExitNode returns hostname of peer with ExitNode=true", () => { const peerMap = { "peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true }, "peer-2": { HostName: "gluetun-den", ExitNode: false, ExitNodeOption: true } }; assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc"); }); test("findActiveExitNode returns empty string when no exit node", () => { const peerMap = { "peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false } }; assert.strictEqual(findActiveExitNode(peerMap), ""); }); test("findActiveExitNode returns empty string for null peerMap", () => { assert.strictEqual(findActiveExitNode(null), ""); }); test("findActiveExitNode falls back to map key when HostName missing", () => { const peerMap = { "key-only": { ExitNode: true } }; assert.strictEqual(findActiveExitNode(peerMap), "key-only"); }); // --- errorMessage / formatError --- test("errorMessage returns user-friendly messages for known actions", () => { assert.strictEqual(errorMessage("up"), "Failed to connect to Tailscale"); assert.strictEqual(errorMessage("connect"), "Failed to connect to Tailscale"); assert.strictEqual(errorMessage("down"), "Failed to disconnect from Tailscale"); assert.strictEqual(errorMessage("disconnect"), "Failed to disconnect from Tailscale"); assert.strictEqual(errorMessage("set"), "Failed to set exit node"); assert.strictEqual(errorMessage("status"), "Failed to read Tailscale status"); assert.strictEqual(errorMessage("clipboard"), "Error copying to clipboard"); }); test("errorMessage returns generic message for unknown command", () => { assert.strictEqual(errorMessage("unknown"), "Tailscale command failed"); }); test("formatError returns base message without detail", () => { assert.strictEqual(formatError("status"), "Failed to read Tailscale status"); assert.strictEqual(formatError("set"), "Failed to set exit node"); }); test("formatError appends and truncates detail to 120 chars", () => { const longDetail = "x".repeat(200); const msg = formatError("up", longDetail); assert.ok(msg.includes("Failed to connect to Tailscale")); assert.ok(msg.endsWith("x".repeat(120))); assert.ok(msg.length < 200); }); test("formatError handles empty or falsy detail gracefully", () => { assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale"); assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale"); }); // --- clipboard --- test("getClipboardCommands returns ordered argv arrays with text appended", () => { const cmds = getClipboardCommands("1.2.3.4"); assert.ok(Array.isArray(cmds)); assert.strictEqual(cmds.length, 2); assert.deepStrictEqual(cmds[0], ["dms", "cl", "copy", "1.2.3.4"]); assert.deepStrictEqual(cmds[1], ["wl-copy", "1.2.3.4"]); }); test("getClipboardCommands handles text with special characters safely (direct argv)", () => { const cmds = getClipboardCommands("it's a 'test' with \"quotes\" and\nnewlines"); assert.ok(Array.isArray(cmds)); assert.strictEqual(cmds.length, 2); assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines")); }); // --- strings --- test("getStrings returns canonical UI strings for the widget", () => { const s = getStrings(); assert.ok(s.header); assert.ok(s.connected); assert.ok(s.disconnected); assert.ok(s.exitNodePrefix); assert.ok(s.none); assert.ok(s.copied); assert.ok(s.invalidExitNodeHostname); assert.ok(s.notConnectedHint); }); test("getStrings.copied is the I18n template key (interpolation via .arg at call site)", () => { const s = getStrings(); assert.strictEqual(s.copied, "Copied %1 to clipboard"); }); // --- toggle helpers --- test("buildToggleCommand returns down when connected", () => { assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]); }); test("buildToggleCommand returns up when disconnected", () => { assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"]); }); test("buildToggleCommand treats null and undefined as disconnected", () => { assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"]); assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]); }); test("commandForPendingAction returns toggle command when pending is TOGGLE and statusOk", () => { assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true, true), ["tailscale", "down"]); assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false, true), ["tailscale", "up"]); assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null, true), ["tailscale", "up"]); }); test("commandForPendingAction returns null when status poll failed (do not invent up/down)", () => { assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, false), null); assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, true, false), null); assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, undefined), null); }); test("commandForPendingAction returns null for no pending action or unknown pending value", () => { assert.strictEqual(commandForPendingAction(null, true, true), null); assert.strictEqual(commandForPendingAction(undefined, false, true), null); assert.strictEqual(commandForPendingAction("something-else", true, true), null); assert.strictEqual(commandForPendingAction("", true, true), null); }); // --- parseStatusResult (#55 and ground-truth parsing) --- test("parseStatusResult produces correct state from valid Running JSON", () => { const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: { "key-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNode: true, ExitNodeOption: true } } }); const state = parseStatusResult(json); assert.strictEqual(state.isConnected, true); assert.strictEqual(state.tailscaleIP, "100.64.0.5"); assert.strictEqual(state.currentExitNode, "router"); assert.strictEqual(state.peers.length, 1); }); test("parseStatusResult returns safe defaults for invalid JSON", () => { const state = parseStatusResult("not json at all"); assert.strictEqual(state.isConnected, false); assert.strictEqual(state.tailscaleIP, ""); assert.strictEqual(state.currentExitNode, ""); assert.deepStrictEqual(state.peers, []); }); test("parseStatusResult handles missing Self gracefully when Running", () => { const json = JSON.stringify({ BackendState: "Running", Peer: {} }); const state = parseStatusResult(json); assert.strictEqual(state.isConnected, true); assert.strictEqual(state.tailscaleIP, ""); }); test("parseStatusResult handles missing and empty Peer gracefully when Running", () => { const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } }); const state = parseStatusResult(json); assert.strictEqual(state.peers.length, 0); assert.strictEqual(state.currentExitNode, ""); }); test("parseStatusResult sets isConnected false for non-Running BackendState", () => { const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} }); const state = parseStatusResult(json); assert.strictEqual(state.isConnected, false); }); // #55: disconnected must not surface peer list / exit node from leftover JSON test("parseStatusResult clears peers and exit node when BackendState is not Running (#55)", () => { const json = JSON.stringify({ BackendState: "Stopped", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: { "k1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: false, ExitNode: true, ExitNodeOption: true } } }); const state = parseStatusResult(json); assert.strictEqual(state.isConnected, false); assert.deepStrictEqual(state.peers, []); assert.strictEqual(state.currentExitNode, ""); // Self IP may still appear in raw JSON; we clear display IP when disconnected // so the popout does not look "half connected". assert.strictEqual(state.tailscaleIP, ""); }); test("parseStatusResult clears peers for NeedsLogin even if Peer map is populated (#55)", () => { const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: { "k1": { HostName: "ghost", TailscaleIPs: ["100.64.0.2"], Online: false } } }); const state = parseStatusResult(json); assert.strictEqual(state.isConnected, false); assert.deepStrictEqual(state.peers, []); assert.strictEqual(state.currentExitNode, ""); assert.strictEqual(state.tailscaleIP, ""); }); // --- getStatusCommand --- test("getStatusCommand returns the canonical tailscale status --json argv", () => { const cmd = getStatusCommand(); assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]); }); // --- PendingAction constant --- test("PendingAction.TOGGLE is the stable string used by the widget", () => { assert.strictEqual(PendingAction.TOGGLE, "toggle"); }); // --- export surface: no over-abstracted UI predicates --- test("lib does not export trivial UI predicates shouldShowClearExitNode / isActiveExitNode", () => { assert.strictEqual(lib.shouldShowClearExitNode, undefined); assert.strictEqual(lib.isActiveExitNode, undefined); });