Extract QML-embedded logic into testable JavaScript modules #35

Closed
opened 2026-05-20 09:33:46 +00:00 by vybe · 1 comment
Collaborator

Problem

The widget's business logic is embedded directly inside TailscaleWidget.qml as inline QML/JavaScript. QML is a UI framework — it cannot be unit tested outside a running Quickshell session. This means any logic living in the QML file is untestable, unverifiable by agents, and requires manual inspection in the live environment to validate.

Issue #12 is a concrete example: clipboard detection and command construction live inline in the QML, with no way to write tests for the detection strategy, whitelist validation, or command-building logic.

Where

Currently, TailscaleWidget.qml contains inline logic for:

  • Clipboard tool detection (lines 62-76) — shell command construction and stdout parsing
  • Clipboard execution (lines 139-147) — string interpolation into shell commands
  • Toggle/connect/disconnect flow (lines 117-128) — conditional command building
  • Exit node setting (lines 130-137) — error handling and toast messaging

Meanwhile, lib.js already has a pattern for testable pure-JS functions (parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage) with a CommonJS export block and a corresponding test/lib.test.js.

Goal

Move as much logic as possible out of QML and into lib.js (or other .js modules), so it can be covered by automated tests. The QML file should be a thin UI layer that calls into testable functions.

Criteria

  1. Any function that takes input and produces output (detection, validation, command construction, data transformation) belongs in lib.js, not inline in QML.
  2. Each extracted function gets test coverage in test/ for both happy path and edge cases (malicious input, empty input, unexpected types).
  3. The QML file should only contain UI declarations, event wiring, and calls to lib functions — no inline business logic.
  4. The lib.js CommonJS export block is updated to export new functions for testing.

Benefits

  • Agents can run node --test to verify correctness after any change
  • Logic changes can be tested iteratively without loading the widget
  • Security-critical paths (shell command construction, input validation) get explicit test coverage
  • The pattern scales: future widgets or features follow the same testable architecture
## Problem The widget's business logic is embedded directly inside `TailscaleWidget.qml` as inline QML/JavaScript. QML is a UI framework — it cannot be unit tested outside a running Quickshell session. This means any logic living in the QML file is untestable, unverifiable by agents, and requires manual inspection in the live environment to validate. Issue #12 is a concrete example: clipboard detection and command construction live inline in the QML, with no way to write tests for the detection strategy, whitelist validation, or command-building logic. ## Where Currently, `TailscaleWidget.qml` contains inline logic for: - Clipboard tool detection (lines 62-76) — shell command construction and stdout parsing - Clipboard execution (lines 139-147) — string interpolation into shell commands - Toggle/connect/disconnect flow (lines 117-128) — conditional command building - Exit node setting (lines 130-137) — error handling and toast messaging Meanwhile, `lib.js` already has a pattern for testable pure-JS functions (`parsePeers`, `makeExitNodeCommand`, `findActiveExitNode`, `errorMessage`) with a CommonJS export block and a corresponding `test/lib.test.js`. ## Goal Move as much logic as possible out of QML and into `lib.js` (or other `.js` modules), so it can be covered by automated tests. The QML file should be a thin UI layer that calls into testable functions. ## Criteria 1. Any function that takes input and produces output (detection, validation, command construction, data transformation) belongs in `lib.js`, not inline in QML. 2. Each extracted function gets test coverage in `test/` for both happy path and edge cases (malicious input, empty input, unexpected types). 3. The QML file should only contain UI declarations, event wiring, and calls to `lib` functions — no inline business logic. 4. The `lib.js` CommonJS export block is updated to export new functions for testing. ## Benefits - Agents can run `node --test` to verify correctness after any change - Logic changes can be tested iteratively without loading the widget - Security-critical paths (shell command construction, input validation) get explicit test coverage - The pattern scales: future widgets or features follow the same testable architecture
Author
Collaborator

Plan: Extract QML-embedded logic into testable JavaScript modules

Tracer Bullet 1 — Clipboard whitelist validation + safe command builder

Scope: Add validateClipboardCmd(cmd) and buildCopyCommand(text, clipboardCmd) to lib.js.

validateClipboardCmd checks a string against the known-safe whitelist: "dms cl copy", "wl-copy", "clipmanctl copy", "none". Returns boolean.

buildCopyCommand takes text and clipboardCmd, validates via the whitelist, and returns a safe command array. Returns null on invalid input.

Tests:

  • Valid whitelist values return true / produce a command
  • Malicious inputs ("rm -rf /", "echo hi; malicious", "$(whoami)") are rejected
  • Empty string, null, undefined are rejected
  • Text with single quotes, newlines, special characters is safely handled

Why first: Directly addresses the security concern in #12 and establishes the extract-then-test pattern.


Tracer Bullet 2 — Clipboard detection result parser

Scope: Add parseClipboardDetection(stdout) to lib.js.

Takes the raw stdout string from the detection Process, trims it, validates it against the whitelist, and returns the validated command string or "none" for anything unrecognized.

Tests:

  • Each expected output string passes through
  • Extra whitespace is trimmed
  • Multi-line output (simulating a compromised which) falls back to "none"
  • Empty output falls back to "none"
  • Unexpected strings fall back to "none"

Tracer Bullet 3 — Toggle command builder

Scope: Add buildToggleCommand(isConnected) to lib.js.

Returns ["tailscale", "up"] or ["tailscale", "down"] based on connection state.

Tests:

  • true returns down command
  • false returns up command
  • Edge cases: null, undefined treated as disconnected (safe default)

Tracer Bullet 4 — Status result parser

Scope: Add parseStatusResult(jsonText) to lib.js.

Takes the raw JSON string from tailscale status --json, parses it, and returns a state object: { isConnected, tailscaleIP, currentExitNode, peers }. Uses existing parsePeers and findActiveExitNode internally.

Tests:

  • Valid JSON with all fields produces correct state object
  • Invalid JSON returns a safe default state object (all false/empty)
  • Missing Self, missing Peer, empty Peer all handled gracefully
  • BackendState other than "Running" sets isConnected to false

Tracer Bullet 5 — Refactor QML to use extracted lib.js functions

Scope: Update TailscaleWidget.qml to call the new lib.js functions instead of inline logic.

Changes:

  • copyToClipboard() calls buildCopyCommand() instead of string interpolation
  • detectClipboard stdout handler calls parseClipboardDetection() instead of raw assignment
  • toggleTailscale() calls buildToggleCommand() instead of inline conditional
  • statusCheck stdout handler calls parseStatusResult() instead of inline parsing

Verification: Existing behavior is preserved. No new functionality, just delegation. Tests from bullets 1-4 still pass.


Summary

# Slice New lib.js functions Test file
1 Clipboard validation + command builder validateClipboardCmd, buildCopyCommand test/clipboard.test.js
2 Detection result parser parseClipboardDetection test/clipboard.test.js
3 Toggle command builder buildToggleCommand test/toggle.test.js
4 Status result parser parseStatusResult test/status.test.js
5 QML refactor (uses all above) existing tests still pass

Bullets 1-4 are pure JS, fully testable with node --test. Bullet 5 is the wiring change that ties it together. Each bullet is independently mergeable.

## Plan: Extract QML-embedded logic into testable JavaScript modules ### Tracer Bullet 1 — Clipboard whitelist validation + safe command builder **Scope:** Add `validateClipboardCmd(cmd)` and `buildCopyCommand(text, clipboardCmd)` to `lib.js`. `validateClipboardCmd` checks a string against the known-safe whitelist: `"dms cl copy"`, `"wl-copy"`, `"clipmanctl copy"`, `"none"`. Returns boolean. `buildCopyCommand` takes `text` and `clipboardCmd`, validates via the whitelist, and returns a safe command array. Returns `null` on invalid input. **Tests:** - Valid whitelist values return true / produce a command - Malicious inputs (`"rm -rf /"`, `"echo hi; malicious"`, `"$(whoami)"`) are rejected - Empty string, `null`, `undefined` are rejected - Text with single quotes, newlines, special characters is safely handled **Why first:** Directly addresses the security concern in #12 and establishes the extract-then-test pattern. --- ### Tracer Bullet 2 — Clipboard detection result parser **Scope:** Add `parseClipboardDetection(stdout)` to `lib.js`. Takes the raw stdout string from the detection Process, trims it, validates it against the whitelist, and returns the validated command string or `"none"` for anything unrecognized. **Tests:** - Each expected output string passes through - Extra whitespace is trimmed - Multi-line output (simulating a compromised `which`) falls back to `"none"` - Empty output falls back to `"none"` - Unexpected strings fall back to `"none"` --- ### Tracer Bullet 3 — Toggle command builder **Scope:** Add `buildToggleCommand(isConnected)` to `lib.js`. Returns `["tailscale", "up"]` or `["tailscale", "down"]` based on connection state. **Tests:** - `true` returns down command - `false` returns up command - Edge cases: `null`, `undefined` treated as disconnected (safe default) --- ### Tracer Bullet 4 — Status result parser **Scope:** Add `parseStatusResult(jsonText)` to `lib.js`. Takes the raw JSON string from `tailscale status --json`, parses it, and returns a state object: `{ isConnected, tailscaleIP, currentExitNode, peers }`. Uses existing `parsePeers` and `findActiveExitNode` internally. **Tests:** - Valid JSON with all fields produces correct state object - Invalid JSON returns a safe default state object (all false/empty) - Missing `Self`, missing `Peer`, empty `Peer` all handled gracefully - `BackendState` other than `"Running"` sets `isConnected` to false --- ### Tracer Bullet 5 — Refactor QML to use extracted lib.js functions **Scope:** Update `TailscaleWidget.qml` to call the new `lib.js` functions instead of inline logic. Changes: - `copyToClipboard()` calls `buildCopyCommand()` instead of string interpolation - `detectClipboard` stdout handler calls `parseClipboardDetection()` instead of raw assignment - `toggleTailscale()` calls `buildToggleCommand()` instead of inline conditional - `statusCheck` stdout handler calls `parseStatusResult()` instead of inline parsing **Verification:** Existing behavior is preserved. No new functionality, just delegation. Tests from bullets 1-4 still pass. --- ### Summary | # | Slice | New lib.js functions | Test file | |---|---|---|---| | 1 | Clipboard validation + command builder | `validateClipboardCmd`, `buildCopyCommand` | `test/clipboard.test.js` | | 2 | Detection result parser | `parseClipboardDetection` | `test/clipboard.test.js` | | 3 | Toggle command builder | `buildToggleCommand` | `test/toggle.test.js` | | 4 | Status result parser | `parseStatusResult` | `test/status.test.js` | | 5 | QML refactor | (uses all above) | existing tests still pass | Bullets 1-4 are pure JS, fully testable with `node --test`. Bullet 5 is the wiring change that ties it together. Each bullet is independently mergeable.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: jtmorris/dms_tailscalectl#35
No description provided.