Compare commits
15 commits
0083fe7dc2
...
2227cb60fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 2227cb60fe | |||
| 701daa9a39 | |||
| cac0ae6bde | |||
| 4fbc4e1594 | |||
| bb50912bb3 | |||
| f87ade7a67 | |||
| 2ece3cc103 | |||
| 6ef933211c | |||
| 93b257ba5d | |||
| b521af3364 | |||
| ce8707c618 | |||
| dddd9218f6 | |||
| 3c31209ff1 | |||
| a86d5e279d | |||
| 7c12793b98 |
5 changed files with 345 additions and 44 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -1,4 +1,16 @@
|
||||||
## Documentation for agents
|
## Documentation for agents
|
||||||
|
|
||||||
|
You **MUST** read each of these documents before contributing to this repository:
|
||||||
|
- Git workflow rules: `docs/agents/git-workflow.md`
|
||||||
- Repo interaction rules: `docs/agents/repo-instructions.md`
|
- Repo interaction rules: `docs/agents/repo-instructions.md`
|
||||||
- Domain model & context rules: `docs/agents/domain.md`
|
- Domain model & context rules: `docs/agents/domain.md`
|
||||||
|
|
||||||
|
## Critical Coding & Version Control Conduct
|
||||||
|
|
||||||
|
- **NEVER** commit directly to `master` or `testing without explicit human instruction.
|
||||||
|
- **NEVER** merge into `master` or `testing` without explict human instruction.
|
||||||
|
- **ALWAYS** create new branches when working on non-trivial coding tasks.
|
||||||
|
- **ALWAYS** commit self-contained logical unit of work.
|
||||||
|
|
||||||
|
## Issues, PRs, and Comments Conduct
|
||||||
|
- **ALWAYS** end your written contributions with `Written by AI agent working for @jtmorris. Model: <MODEL NAME>.`. Replace `<MODEL NAME>` with the LLM model, version, and, if relevant, number of parameters. For example: `Claude Sonnet 4.7`, `Grok 4.3`, `Qwen 3.6 27B`.
|
||||||
106
docs/agents/git-workflow.md
Normal file
106
docs/agents/git-workflow.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Git Workflow — Branching Strategy and Rules
|
||||||
|
|
||||||
|
## Branch Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
feature_* → vibes → testing → master
|
||||||
|
(work) (agent) (beta) (stable)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `master`
|
||||||
|
- **Purpose**: Stable, tested, production-ready code. What end-users run.
|
||||||
|
- **Who commits**: Human only. Agents **must not** commit or merge here without explicit human instruction.
|
||||||
|
- **Requirements**: All tests pass. Code reviewed and approved by human.
|
||||||
|
|
||||||
|
### `testing`
|
||||||
|
- **Purpose**: Beta-quality code. May contain bugs. Not guaranteed safe.
|
||||||
|
- **Who commits**: Human only. Agents **must not** commit or merge here without explicit human instruction.
|
||||||
|
- **Requirements**: Human has reviewed `vibes` and approved promotion.
|
||||||
|
|
||||||
|
### `vibes`
|
||||||
|
- **Purpose**: Agent integration branch. Completed features, bug fixes, and tasks land here for human review.
|
||||||
|
- **Who commits**: Agents merge completed feature branches here. Human reviews and either approves (promotes to `testing`) or rejects (sends agent back to feature branch).
|
||||||
|
- **Requirements**: Feature branch's tests pass. Feature branch work is complete per task scope.
|
||||||
|
|
||||||
|
### `feature_<slug>`
|
||||||
|
- **Purpose**: Isolated work branches for a single feature, bug fix, or task.
|
||||||
|
- **Who commits**: Agents and humans.
|
||||||
|
- **Naming**: `feature_` prefix followed by a short, git-safe slug describing the task (e.g., `feature_add_toast_notifications`, `fix_exit_node_lookup`).
|
||||||
|
- **Branch point**: Usually off `master`. If chaining tasks or building on unmerged work, branch off `vibes` or `testing` as appropriate.
|
||||||
|
|
||||||
|
## Commit Discipline
|
||||||
|
|
||||||
|
Agents must commit frequently, after each self-contained logical unit of work. **Do not batch all changes into a single commit.**
|
||||||
|
|
||||||
|
- Commit after each passing test, completed function, or logical change.
|
||||||
|
- Each commit should be small, focused, and meaningful on its own.
|
||||||
|
- Every commit must be traceable, reversible, and not break existing tests.
|
||||||
|
- Granular commits enable easy bisecting, clear history, and precise rollbacks.
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Use conventional commit format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>: <short description>
|
||||||
|
|
||||||
|
<optional body explaining why>
|
||||||
|
```
|
||||||
|
|
||||||
|
Types: `feat`, `fix`, `refactor`, `docs`, `style`, `test`, `chore`, `perf`, `ci`, `build`.
|
||||||
|
|
||||||
|
Messages must explain **what** changed and **why**. Avoid vague messages like "fix stuff" or "update code".
|
||||||
|
|
||||||
|
## Rebasing & Syncing
|
||||||
|
|
||||||
|
Before merging a feature branch into `vibes`:
|
||||||
|
|
||||||
|
1. Rebase the feature branch onto the latest state of its base branch (`master`, `vibes`, or `testing`).
|
||||||
|
2. Resolve any conflicts during rebase.
|
||||||
|
3. Verify tests still pass after rebase.
|
||||||
|
|
||||||
|
This keeps history linear and avoids unnecessary merge commits.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Starting Work
|
||||||
|
1. Create a new `feature_<slug>` branch from the appropriate base branch (default: `master`).
|
||||||
|
2. Push the feature branch to remote.
|
||||||
|
3. Implement the feature, bug fix, or task.
|
||||||
|
4. Commit frequently (see Commit Discipline above).
|
||||||
|
5. Run tests after each commit. Ensure project-wide testing strategy passes.
|
||||||
|
|
||||||
|
### Completing Work (Agent)
|
||||||
|
1. Rebase feature branch onto latest base branch.
|
||||||
|
2. Merge the feature branch into `vibes` (preserve individual commits, do not squash).
|
||||||
|
3. Push `vibes` to remote.
|
||||||
|
4. Notify human that work is on `vibes` for review. **Stop and wait for human feedback.**
|
||||||
|
|
||||||
|
### Human Review
|
||||||
|
1. Human reviews `vibes` branch.
|
||||||
|
2. **If approved**: Human merges `vibes` → `testing`.
|
||||||
|
3. **If rejected**: Human communicates what needs fixing. Agent returns to the feature branch (or creates a new one) to address issues.
|
||||||
|
|
||||||
|
### Promoting to Stable
|
||||||
|
1. Human validates `testing` branch is ready.
|
||||||
|
2. Human merges `testing` → `master`.
|
||||||
|
|
||||||
|
## Agent Guardrails
|
||||||
|
|
||||||
|
- **NEVER** commit directly to `master` or `testing` without explicit human instruction.
|
||||||
|
- **NEVER** merge into `master` or `testing` without explicit human instruction.
|
||||||
|
- Always work in a `feature_<slug>` branch.
|
||||||
|
- Merge completed work to `vibes`, then stop and wait for human review.
|
||||||
|
- Preserve individual commits when merging — do not squash.
|
||||||
|
- Rebase feature branches before merging to `vibes` — do not create merge commits.
|
||||||
|
|
||||||
|
## Branch Management
|
||||||
|
|
||||||
|
- `feature_*` branches may be deleted after merging to `vibes` if no longer needed.
|
||||||
|
- If work is rejected from `vibes`, keep the feature branch for iterative fixes.
|
||||||
|
- `vibes` should be kept in a mergeable state — do not let it accumulate broken code.
|
||||||
|
- Push feature branches to remote for backup and visibility.
|
||||||
|
|
||||||
|
## Quick Spikes & Exploration
|
||||||
|
|
||||||
|
For quick local exploration or solo spikes, use `.scratch/<feature-slug>/` as documented in `docs/agents/repo-instructions.md`. When spike work becomes real, move it to a proper `feature_*` branch.
|
||||||
|
|
@ -15,7 +15,10 @@ PluginComponent {
|
||||||
property string tailscaleIP: ""
|
property string tailscaleIP: ""
|
||||||
property string currentExitNode: ""
|
property string currentExitNode: ""
|
||||||
property var peers: []
|
property var peers: []
|
||||||
property string clipboardCmd: ""
|
property string cachedClipboardTool: ""
|
||||||
|
property string _copyText: ""
|
||||||
|
property string _copyCurrentTool: ""
|
||||||
|
property int _copyAttempted: 0
|
||||||
|
|
||||||
layerNamespacePlugin: "tailscalectl"
|
layerNamespacePlugin: "tailscalectl"
|
||||||
popoutWidth: 360
|
popoutWidth: 360
|
||||||
|
|
@ -29,16 +32,19 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
detectClipboard.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: toggleProcess
|
id: toggleProcess
|
||||||
|
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
var action = root.isConnected ? "disconnect" : "connect"
|
var action = root.isConnected ? "disconnect" : "connect"
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action))
|
var detail = toggleProcess.stderr.text.trim().slice(0, 120)
|
||||||
|
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : ""))
|
||||||
}
|
}
|
||||||
statusCheck.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
@ -46,35 +52,40 @@ PluginComponent {
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: copyProcess
|
id: copyProcess
|
||||||
|
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code === 0) {
|
||||||
|
root.cachedClipboardTool = root._copyCurrentTool
|
||||||
|
ToastService.showInfo("Copied " + root._copyText + " to clipboard")
|
||||||
|
} else {
|
||||||
|
root._copyAttempted += 1
|
||||||
|
if (root._copyAttempted < TailscaleLib.allClipboardTools().length) {
|
||||||
|
root._copyCurrentTool = TailscaleLib.nextClipboardTool(root._copyCurrentTool)
|
||||||
|
root._executeCopy()
|
||||||
|
} else {
|
||||||
|
var detail = copyProcess.stderr.text.trim().slice(0, 120)
|
||||||
|
ToastService.showError("tailscalectl", "No clipboard tool found" + (detail ? " — " + detail : ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: exitNodeProcess
|
id: exitNodeProcess
|
||||||
|
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set"))
|
var detail = exitNodeProcess.stderr.text.trim().slice(0, 120)
|
||||||
|
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set") + (detail ? " — " + detail : ""))
|
||||||
}
|
}
|
||||||
statusCheck.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
|
||||||
id: detectClipboard
|
|
||||||
|
|
||||||
command: ["sh", "-c", "which dms >/dev/null 2>&1 && echo 'dms cl copy' || which wl-copy >/dev/null 2>&1 && echo 'wl-copy' || which clipmanctl >/dev/null 2>&1 && echo 'clipmanctl copy' || echo none"]
|
|
||||||
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
root.clipboardCmd = this.text.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onExited: (code, status) => {
|
|
||||||
statusCheck.running = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: statusCheck
|
id: statusCheck
|
||||||
|
|
||||||
|
|
@ -82,19 +93,11 @@ PluginComponent {
|
||||||
|
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
try {
|
const state = TailscaleLib.parseStatusResult(this.text)
|
||||||
const data = JSON.parse(this.text)
|
root.isConnected = state.isConnected
|
||||||
root.isConnected = data.BackendState === "Running"
|
root.tailscaleIP = state.tailscaleIP
|
||||||
root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || ""
|
root.currentExitNode = state.currentExitNode
|
||||||
root.currentExitNode = TailscaleLib.findActiveExitNode(data.Peer || {})
|
root.peers = state.peers
|
||||||
root.peers = TailscaleLib.parsePeers(data.Peer || {})
|
|
||||||
} catch (e) {
|
|
||||||
root.isConnected = false
|
|
||||||
root.tailscaleIP = ""
|
|
||||||
root.currentExitNode = ""
|
|
||||||
root.peers = []
|
|
||||||
ToastService.showError("tailscalectl", "Failed to parse Tailscale status")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,11 +122,7 @@ PluginComponent {
|
||||||
ToastService.showError("tailscalectl", "Tailscale not available")
|
ToastService.showError("tailscalectl", "Tailscale not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (root.isConnected) {
|
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
|
||||||
toggleProcess.command = ["tailscale", "down"]
|
|
||||||
} else {
|
|
||||||
toggleProcess.command = ["tailscale", "up"]
|
|
||||||
}
|
|
||||||
toggleProcess.running = true
|
toggleProcess.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,13 +136,24 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
if (!root.clipboardCmd || root.clipboardCmd === "none") {
|
root._copyText = text
|
||||||
ToastService.showError("tailscalectl", "No clipboard tool found")
|
root._copyAttempted = 0
|
||||||
|
if (root.cachedClipboardTool) {
|
||||||
|
root._copyCurrentTool = root.cachedClipboardTool
|
||||||
|
} else {
|
||||||
|
root._copyCurrentTool = TailscaleLib.allClipboardTools()[0]
|
||||||
|
}
|
||||||
|
root._executeCopy()
|
||||||
|
}
|
||||||
|
|
||||||
|
function _executeCopy() {
|
||||||
|
var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool)
|
||||||
|
if (!cmd) {
|
||||||
|
ToastService.showError("tailscalectl", "Invalid clipboard command")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
copyProcess.command = ["sh", "-c", "printf '%s' '" + text.replace(/'/g, "'\\''") + "' | " + root.clipboardCmd]
|
copyProcess.command = cmd
|
||||||
copyProcess.running = true
|
copyProcess.running = true
|
||||||
ToastService.showInfo("Copied " + text + " to clipboard")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
popoutContent: Component {
|
popoutContent: Component {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,54 @@ function findActiveExitNode(peerMap) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"]
|
||||||
|
|
||||||
|
var clipboardCmdMap = {
|
||||||
|
"dms": "dms cl copy",
|
||||||
|
"wl-copy": "wl-copy",
|
||||||
|
"clipmanctl": "clipmanctl copy"
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateClipboardTool(tool) {
|
||||||
|
return typeof tool === "string" && safeClipboardTools.includes(tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCopyCommand(text, tool) {
|
||||||
|
if (!validateClipboardTool(tool)) return null
|
||||||
|
var cmd = clipboardCmdMap[tool]
|
||||||
|
if (!cmd) return null
|
||||||
|
var escaped = text.replace(/'/g, "'\\''")
|
||||||
|
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd]
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextClipboardTool(currentTool) {
|
||||||
|
var idx = safeClipboardTools.indexOf(currentTool)
|
||||||
|
if (idx < 0) return safeClipboardTools[0]
|
||||||
|
return safeClipboardTools[(idx + 1) % safeClipboardTools.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function allClipboardTools() {
|
||||||
|
return safeClipboardTools.slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatusResult(jsonText) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonText)
|
||||||
|
return {
|
||||||
|
isConnected: data.BackendState === "Running",
|
||||||
|
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
||||||
|
currentExitNode: findActiveExitNode(data.Peer || {}),
|
||||||
|
peers: parsePeers(data.Peer || {})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToggleCommand(isConnected) {
|
||||||
|
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(cmd) {
|
function errorMessage(cmd) {
|
||||||
var messages = {
|
var messages = {
|
||||||
"up": "Failed to connect to Tailscale",
|
"up": "Failed to connect to Tailscale",
|
||||||
|
|
@ -40,5 +88,5 @@ function errorMessage(cmd) {
|
||||||
|
|
||||||
// CommonJS export for Node.js tests (ignored by QML)
|
// CommonJS export for Node.js tests (ignored by QML)
|
||||||
if (typeof module !== "undefined" && module.exports) {
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage }
|
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
test/lib.test.js
127
test/lib.test.js
|
|
@ -1,7 +1,7 @@
|
||||||
import { test } from "node:test"
|
import { test } from "node:test"
|
||||||
import assert from "node:assert"
|
import assert from "node:assert"
|
||||||
import lib from "../tailscalectl/lib.js"
|
import lib from "../tailscalectl/lib.js"
|
||||||
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage } = lib
|
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib
|
||||||
|
|
||||||
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||||
const peerMap = {
|
const peerMap = {
|
||||||
|
|
@ -74,3 +74,128 @@ test("errorMessage returns generic message for unknown command", () => {
|
||||||
const msg = errorMessage("unknown", 1)
|
const msg = errorMessage("unknown", 1)
|
||||||
assert.strictEqual(msg, "Tailscale command failed")
|
assert.strictEqual(msg, "Tailscale command failed")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- validateClipboardTool ---
|
||||||
|
|
||||||
|
test("validateClipboardTool accepts whitelisted tool names", () => {
|
||||||
|
assert.strictEqual(validateClipboardTool("dms"), true)
|
||||||
|
assert.strictEqual(validateClipboardTool("wl-copy"), true)
|
||||||
|
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validateClipboardTool rejects malicious inputs", () => {
|
||||||
|
assert.strictEqual(validateClipboardTool("rm -rf /"), false)
|
||||||
|
assert.strictEqual(validateClipboardTool("echo hi; malicious"), false)
|
||||||
|
assert.strictEqual(validateClipboardTool("$(whoami)"), false)
|
||||||
|
assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validateClipboardTool rejects empty and falsy inputs", () => {
|
||||||
|
assert.strictEqual(validateClipboardTool(""), false)
|
||||||
|
assert.strictEqual(validateClipboardTool(null), false)
|
||||||
|
assert.strictEqual(validateClipboardTool(undefined), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- buildCopyCommand ---
|
||||||
|
|
||||||
|
test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => {
|
||||||
|
const cmd = buildCopyCommand("hello", "wl-copy")
|
||||||
|
assert.ok(Array.isArray(cmd))
|
||||||
|
assert.strictEqual(cmd[0], "sh")
|
||||||
|
assert.strictEqual(cmd[1], "-c")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("buildCopyCommand returns null for invalid clipboard tool", () => {
|
||||||
|
assert.strictEqual(buildCopyCommand("hello", "rm -rf /"), null)
|
||||||
|
assert.strictEqual(buildCopyCommand("hello", ""), null)
|
||||||
|
assert.strictEqual(buildCopyCommand("hello", null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("buildCopyCommand safely handles text with special characters", () => {
|
||||||
|
const cmd = buildCopyCommand("it's a 'test' with \"quotes\" and\nnewlines", "wl-copy")
|
||||||
|
assert.ok(Array.isArray(cmd))
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- nextClipboardTool ---
|
||||||
|
|
||||||
|
test("nextClipboardTool cycles through tools", () => {
|
||||||
|
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
|
||||||
|
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
|
||||||
|
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("nextClipboardTool falls back to first tool for unknown input", () => {
|
||||||
|
assert.strictEqual(nextClipboardTool(""), "dms")
|
||||||
|
assert.strictEqual(nextClipboardTool("unknown"), "dms")
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- allClipboardTools ---
|
||||||
|
|
||||||
|
test("allClipboardTools returns the list of safe tools", () => {
|
||||||
|
const tools = allClipboardTools()
|
||||||
|
assert.ok(Array.isArray(tools))
|
||||||
|
assert.strictEqual(tools.length, 3)
|
||||||
|
assert.ok(tools.includes("dms"))
|
||||||
|
assert.ok(tools.includes("wl-copy"))
|
||||||
|
assert.ok(tools.includes("clipmanctl"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 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"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- parseStatusResult ---
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue