- Add PendingAction constant and commandForPendingAction pure function (fully tested) - Remove root _pendingToggle property - Use dynamic _pendingAction on the statusCheck Process for the fresh-status hand-off - Introduce refreshStatus() helper and deduplicate the two post-action refresh sites - All JS statements now terminate with semicolons; all blocks use braces - Behavior unchanged; coordination token no longer leaks to widget root - Enables future post-status actions without adding more root properties Written by AI agent working for @jtmorris. Model: grok-build-0.1.
262 lines
10 KiB
JavaScript
262 lines
10 KiB
JavaScript
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, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction } = lib
|
|
|
|
/*
|
|
* Unit tests for the pure functions exported from lib.js.
|
|
*
|
|
* All functions in lib.js are exercised via Node's built-in test runner.
|
|
*
|
|
* TailscaleWidget.qml has no automated test coverage. The four Process
|
|
* objects, their onExited handlers, and all widget UI behavior must be
|
|
* verified manually.
|
|
*/
|
|
|
|
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[0].exitNode, true)
|
|
assert.strictEqual(peers[1].exitNode, false)
|
|
})
|
|
|
|
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("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("errorMessage returns user-friendly message for tailscale up failure", () => {
|
|
const msg = errorMessage("up", 1)
|
|
assert.strictEqual(msg, "Failed to connect to Tailscale")
|
|
})
|
|
|
|
test("errorMessage returns user-friendly message for tailscale down failure", () => {
|
|
const msg = errorMessage("down", 1)
|
|
assert.strictEqual(msg, "Failed to disconnect from Tailscale")
|
|
})
|
|
|
|
test("errorMessage returns user-friendly message for tailscale set failure", () => {
|
|
const msg = errorMessage("set", 1)
|
|
assert.strictEqual(msg, "Failed to set exit node")
|
|
})
|
|
|
|
test("errorMessage returns user-friendly message for tailscale status failure", () => {
|
|
const msg = errorMessage("status", 1)
|
|
assert.strictEqual(msg, "Failed to read Tailscale status")
|
|
})
|
|
|
|
test("errorMessage returns generic message for unknown command", () => {
|
|
const msg = errorMessage("unknown", 1)
|
|
assert.strictEqual(msg, "Tailscale command failed")
|
|
})
|
|
|
|
// --- getClipboardCommands ---
|
|
|
|
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"))
|
|
})
|
|
|
|
test("getClipboardCommands is deterministic and open for future tools", () => {
|
|
const cmds = getClipboardCommands("foo")
|
|
assert.ok(Array.isArray(cmds[0]))
|
|
assert.ok(Array.isArray(cmds[1]))
|
|
})
|
|
|
|
// --- getStrings ---
|
|
|
|
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.copied)
|
|
})
|
|
|
|
test("getStrings.copied interpolates the text", () => {
|
|
const s = getStrings()
|
|
const msg = s.copied("100.64.0.5")
|
|
assert.ok(msg.includes("100.64.0.5"))
|
|
})
|
|
|
|
test("shouldShowClearExitNode returns true only when there is a current exit node", () => {
|
|
assert.strictEqual(shouldShowClearExitNode("router"), true)
|
|
assert.strictEqual(shouldShowClearExitNode(""), false)
|
|
})
|
|
|
|
test("isActiveExitNode correctly identifies the active exit node button", () => {
|
|
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-sjc"), true)
|
|
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-den"), false)
|
|
assert.strictEqual(isActiveExitNode("", "router"), false)
|
|
})
|
|
|
|
// --- buildToggleCommand ---
|
|
|
|
test("buildToggleCommand returns down command when connected", () => {
|
|
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"])
|
|
})
|
|
|
|
test("buildToggleCommand returns up command 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 passes through buildToggleCommand logic", () => {
|
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true), ["tailscale", "down"]);
|
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false), ["tailscale", "up"]);
|
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null), ["tailscale", "up"]);
|
|
});
|
|
|
|
test("commandForPendingAction returns null for no pending action or unknown pending value", () => {
|
|
assert.strictEqual(commandForPendingAction(null, true), null);
|
|
assert.strictEqual(commandForPendingAction(undefined, false), null);
|
|
assert.strictEqual(commandForPendingAction("something-else", true), null);
|
|
});
|
|
|
|
test("parseStatusResult produces correct state from valid 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.strictEqual(state.peers.length, 0)
|
|
})
|
|
|
|
test("parseStatusResult handles missing Self gracefully", () => {
|
|
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", () => {
|
|
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)
|
|
})
|
|
|
|
// --- formatError (central error + detail formatting) ---
|
|
|
|
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", () => {
|
|
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")
|
|
})
|
|
|
|
// --- isValidExitNodeHostname + makeExitNodeCommand safety ---
|
|
|
|
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 rejects obvious injection attempts", () => {
|
|
["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)"].forEach(h =>
|
|
assert.strictEqual(isValidExitNodeHostname(h), false, h)
|
|
)
|
|
})
|
|
|
|
test("makeExitNodeCommand returns null for invalid hostname", () => {
|
|
assert.strictEqual(makeExitNodeCommand("; rm"), null)
|
|
assert.strictEqual(makeExitNodeCommand("$(whoami)"), 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"])
|
|
})
|
|
|
|
// --- getStatusCommand ---
|
|
|
|
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
|
const cmd = getStatusCommand()
|
|
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"])
|
|
})
|