diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index 1a25c30..9c54120 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -16,7 +16,6 @@ PluginComponent { property var peers: [] property string _copyText: "" property int _copyIndex: 0 - property bool _pendingToggle: false // transient one-shot for post-action verification (to be removed in first-principles refactor) layerNamespacePlugin: "tailscalectl" popoutWidth: 360 @@ -34,11 +33,11 @@ PluginComponent { onExited: (code, status) => { if (code !== 0) { - var action = root.isConnected ? "disconnect" : "connect" - var detail = toggleProcess.stderr.text.trim() - ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail)) + var action = root.isConnected ? "disconnect" : "connect"; + var detail = toggleProcess.stderr.text.trim(); + ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail)); } - statusCheck.running = true + root.refreshStatus(); } } @@ -66,10 +65,10 @@ PluginComponent { onExited: (code, status) => { if (code !== 0) { - var detail = exitNodeProcess.stderr.text.trim() - ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail)) + var detail = exitNodeProcess.stderr.text.trim(); + ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail)); } - statusCheck.running = true + root.refreshStatus(); } } @@ -95,19 +94,22 @@ PluginComponent { ToastService.showError("tailscalectl", TailscaleLib.formatError("status")) } - // Post-action verification: if a toggle was requested, use the fresh state we just received. - if (root._pendingToggle) { - root._pendingToggle = false - toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected) - toggleProcess.running = true + const cmd = TailscaleLib.commandForPendingAction(statusCheck._pendingAction, root.isConnected); + if (cmd) { + toggleProcess.command = cmd; + toggleProcess.running = true; } + statusCheck._pendingAction = null; } } function toggleTailscale() { - // Post-action verification: force a fresh status check, then act on the real ground truth. - root._pendingToggle = true - statusCheck.running = true + statusCheck._pendingAction = "toggle"; + statusCheck.running = true; + } + + function refreshStatus() { + statusCheck.running = true; } function setExitNode(hostname) { diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index 2879eac..c4493bc 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -125,7 +125,17 @@ function formatError(action, detail) { return base; } -// CommonJS export for Node.js tests (ignored by QML) -if (typeof module !== "undefined" && module.exports) { - module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } +const PendingAction = Object.freeze({ + TOGGLE: "toggle" +}); + +function commandForPendingAction(pending, freshIsConnected) { + if (pending === PendingAction.TOGGLE) { + return buildToggleCommand(freshIsConnected); + } + return null; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction }; } diff --git a/test/lib.test.js b/test/lib.test.js index c5ab20b..0f6dce6 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -1,7 +1,7 @@ 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 } = lib +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. @@ -151,7 +151,17 @@ test("buildToggleCommand treats null and undefined as disconnected", () => { assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]) }) -// --- parseStatusResult --- +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({