Files
insta360-link-tools/tests/test_joystick_logic.js
T
brad fa2d4e89d8 Resolve linkctl explicitly in the widget, and surface failures
The widget could not run linkctl even though the binary worked fine from a
terminal and through linkctl-macro.

Plasma executes applet commands with KProcess::setShellCommand, which is
`/bin/sh -c` inheriting plasmashell's own environment. That environment never
sources a login profile, so any install that puts linkctl on PATH from a shell
rc file is invisible to the widget: mise and asdf shims, and ~/.local/bin on
some distributions. bin/linkctl-macro has carried a resolver for exactly this
reason since it was written, and the widget should have had one too rather
than trusting a bare name. It now searches the same locations at startup,
caches the absolute path, and holds commands back until it has one.

Three further changes, all about not failing silently.

The status line is now always visible. It previously appeared only while the
pan and tilt readout was unknown, so any failure after the first successful
poll was hidden behind a stale position.

errorMessage explains the exit codes that are easy to misread. 127 is the shell
failing to run the binary rather than anything about the camera, and 3 most
often means a pinned device node has moved, which happens because the Link's
/dev/videoN number is not stable across power cycles or replugs.

A watchdog clears the busy flag after four seconds. Plasma's executable engine
silently drops a request while an identical command is still running, so a lost
callback could leave busy set and freeze every subsequent step with nothing to
indicate why.

Also corrected the focus timing for the keyboard support: Component.onCompleted
can run before the popup is on screen, where the focus grab does not stick, so
the panel claims focus whenever the popup opens and clears held keys when it
closes.

Tested: 164 checks across seven suites, all passing. tests/test_resolver.sh is
new and runs the generated resolver in a real shell against real directories,
confirming it finds a binary on PATH, finds mise shims, ~/.local/bin and
~/.cargo/bin when they are not on PATH, prefers PATH when both exist, ignores
a non-executable file, and prints nothing when the binary is genuinely absent.
11 new node checks cover the command's shape and confirm a hostile configured
name stays quoted.

Not verified against a live Plasma session. The PATH mechanism is established
from KProcess's documented behaviour and the resolver from a real shell, but
whether this was the actual cause on the reporter's machine is unconfirmed.
2026-10-07 22:51:23 -07:00

172 lines
8.3 KiB
JavaScript

/*
* Tests for the joystick applet's pure helpers.
* Run: node tests/test_joystick_logic.js
*
* logic.js is a QML .js library, so it is loaded by stripping the `.pragma`
* line (meaningless to node) and evaluating the rest. That keeps one copy of
* the logic: the file under test is the file the applet ships.
*/
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const src = fs.readFileSync(
path.join(__dirname, "../plasmoid/package/contents/ui/logic.js"), "utf8");
const ctx = { Math, JSON, String };
vm.createContext(ctx);
vm.runInContext(src.replace(/^\s*\.pragma\s+library\s*$/m, ""), ctx);
let checks = 0, failures = 0;
function eq(got, want, label) {
checks++;
if (got !== want) {
failures++;
console.log(`FAIL ${label}\n got: ${got}\n want: ${want}`);
}
}
function deep(got, want, label) {
eq(JSON.stringify(got), JSON.stringify(want), label);
}
// --- shq: the shell boundary -------------------------------------------
eq(ctx.shq("home"), "'home'", "shq plain");
eq(ctx.shq("my desk"), "'my desk'", "shq space");
eq(ctx.shq("it's"), "'it'\\''s'", "shq embedded quote");
eq(ctx.shq("a; rm -rf /"), "'a; rm -rf /'", "shq neutralises separator");
eq(ctx.shq("$(whoami)"), "'$(whoami)'", "shq neutralises substitution");
eq(ctx.shq("`id`"), "'`id`'", "shq neutralises backticks");
// A preset name chosen to break out of the quoting must stay inert.
eq(ctx.shq("x'; touch /tmp/pwned; '"), "'x'\\''; touch /tmp/pwned; '\\'''",
"shq contains an escape attempt");
// --- baseCmd -----------------------------------------------------------
eq(ctx.baseCmd("linkctl", ""), "'linkctl' --force --json", "baseCmd plain");
eq(ctx.baseCmd("", ""), "'linkctl' --force --json", "baseCmd defaults binary");
eq(ctx.baseCmd("/usr/local/bin/linkctl", "/dev/video0"),
"'/usr/local/bin/linkctl' --force --json --device '/dev/video0'",
"baseCmd with device");
// --- tickCommand -------------------------------------------------------
const B = "'linkctl' --force --json";
eq(ctx.tickCommand(B, 0, 0, 6, false, false), "", "centred stick does nothing");
eq(ctx.tickCommand(B, 0.01, 0.01, 6, false, false), "",
"tiny deflection does nothing");
eq(ctx.tickCommand(B, 1, 0, 6, false, false), B + " right 6.0", "full right");
eq(ctx.tickCommand(B, -1, 0, 6, false, false), B + " left 6.0", "full left");
eq(ctx.tickCommand(B, 0, 1, 6, false, false), B + " up 6.0", "full up");
eq(ctx.tickCommand(B, 0, -1, 6, false, false), B + " down 6.0", "full down");
eq(ctx.tickCommand(B, 0.5, 0, 6, false, false), B + " right 3.0",
"half deflection halves the step");
// Diagonal: two single-axis calls chained.
eq(ctx.tickCommand(B, 1, 1, 6, false, false),
B + " right 6.0 ; " + B + " up 6.0", "diagonal chains both axes");
// A mostly-horizontal push must not creep vertically.
eq(ctx.tickCommand(B, 1, 0.05, 6, false, false), B + " right 6.0",
"small off-axis component is dropped");
// Inversion
eq(ctx.tickCommand(B, 1, 0, 6, true, false), B + " left 6.0", "invert pan");
eq(ctx.tickCommand(B, 0, 1, 6, false, true), B + " down 6.0", "invert tilt");
eq(ctx.tickCommand(B, 1, 1, 6, true, true), B + " left 6.0 ; " + B + " down 6.0",
"invert both");
// --- vectorFromKeys ----------------------------------------------------
const K = (o) => Object.assign(
{ left: false, right: false, up: false, down: false }, o);
deep(ctx.vectorFromKeys(K({})), { nx: 0, ny: 0 }, "no keys held is centred");
deep(ctx.vectorFromKeys(K({ right: true })), { nx: 1, ny: 0 }, "right key");
deep(ctx.vectorFromKeys(K({ left: true })), { nx: -1, ny: 0 }, "left key");
deep(ctx.vectorFromKeys(K({ up: true })), { nx: 0, ny: 1 }, "up key");
deep(ctx.vectorFromKeys(K({ down: true })), { nx: 0, ny: -1 }, "down key");
// Opposing keys must cancel, not fight or latch.
deep(ctx.vectorFromKeys(K({ left: true, right: true })), { nx: 0, ny: 0 },
"left and right cancel");
deep(ctx.vectorFromKeys(K({ up: true, down: true })), { nx: 0, ny: 0 },
"up and down cancel");
deep(ctx.vectorFromKeys(K({ left: true, right: true, up: true })),
{ nx: 0, ny: 1 }, "cancelling one axis leaves the other");
// A diagonal must not be faster than a straight push.
const diag = ctx.vectorFromKeys(K({ right: true, up: true }));
eq(Math.round(Math.sqrt(diag.nx * diag.nx + diag.ny * diag.ny) * 1e6) / 1e6, 1,
"diagonal is normalised to length 1");
eq(diag.nx.toFixed(4), "0.7071", "diagonal x component");
eq(diag.ny.toFixed(4), "0.7071", "diagonal y component");
// And a diagonal still produces a command on both axes at the default step.
eq(ctx.tickCommand(B, diag.nx, diag.ny, 6, false, false),
B + " right 4.2 ; " + B + " up 4.2", "diagonal keys move both axes");
// All four held at once is a standstill, not a crash.
deep(ctx.vectorFromKeys(
K({ left: true, right: true, up: true, down: true })),
{ nx: 0, ny: 0 }, "all four keys cancel out");
// --- absorb ------------------------------------------------------------
deep(ctx.absorb('{"pan":12.3,"tilt":null}'), { pan: 12.3 },
"absorb takes pan, ignores null tilt");
// The diagonal case: two objects, each carrying one axis.
deep(ctx.absorb('{"pan":12.3,"tilt":null}\n{"pan":null,"tilt":-4}'),
{ pan: 12.3, tilt: -4 }, "absorb merges both axes across lines");
deep(ctx.absorb('{"tracking":true}'), { tracking: true }, "absorb tracking true");
deep(ctx.absorb('{"tracking":false}'), { tracking: false },
"absorb tracking false is kept, not treated as absent");
deep(ctx.absorb('{"state":"inactive","pan":null}'), { state: "inactive" },
"absorb state");
deep(ctx.absorb('{"pan":0,"tilt":0}'), { pan: 0, tilt: 0 },
"absorb keeps zero values");
deep(ctx.absorb(""), {}, "absorb empty");
deep(ctx.absorb("not json at all"), {}, "absorb ignores non-json");
deep(ctx.absorb('{"pan":1}\nbroken {\n{"tilt":2}'), { pan: 1, tilt: 2 },
"absorb survives a malformed line between good ones");
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
// --- resolveCommand ----------------------------------------------------
// Plasma runs applet commands through `sh -c` with plasmashell's environment,
// which never sources a login profile. A bare name therefore has to be
// searched for explicitly or the widget cannot run it at all.
const rc = ctx.resolveCommand("linkctl");
eq(rc.indexOf("n='linkctl';") === 0, true, "resolveCommand sets the name first");
eq(rc.includes('command -v "$n"'), true, "resolveCommand tries PATH first");
eq(rc.includes('"$HOME/.local/share/mise/shims"'), true,
"resolveCommand searches mise shims");
eq(rc.includes('"$HOME/.local/bin"'), true, "resolveCommand searches ~/.local/bin");
eq(rc.includes("/usr/local/bin"), true, "resolveCommand searches /usr/local/bin");
eq(rc.includes('[ -x "$d/$n" ]'), true, "resolveCommand tests for executable");
// $HOME must reach the shell unquoted-but-protected, never single-quoted,
// or it would be looked up literally.
eq(rc.includes("'$HOME"), false, "resolveCommand does not single-quote $HOME");
// A hostile configured name must stay inert rather than running.
const rcEvil = ctx.resolveCommand("x'; touch /tmp/pwned; '");
eq(rcEvil.indexOf("n='x'\\''; touch /tmp/pwned; '\\'''") === 0, true,
"resolveCommand quotes a malicious binary name");
// --- errorMessage ------------------------------------------------------
eq(ctx.errorMessage("", "", 127),
"linkctl could not be run. Set its full path in settings.",
"errorMessage explains exit 127 as a missing binary");
eq(ctx.errorMessage("", "", 3).includes("node number changes"), true,
"errorMessage points at a stale pinned device for exit 3");
eq(ctx.errorMessage("", "", 6).includes("video"), true,
"errorMessage names the video group for exit 6");
// A real message from linkctl still wins over the canned text.
eq(ctx.errorMessage('{"error":"something specific"}', "", 3),
"something specific", "errorMessage prefers linkctl's own message");
eq(ctx.errorMessage('{"error":"camera not found"}', "", 3), "camera not found",
"errorMessage prefers the json error");
eq(ctx.errorMessage("", "permission denied", 6), "permission denied",
"errorMessage falls back to stderr");
eq(ctx.errorMessage("", "", 7), "linkctl exited 7",
"errorMessage falls back to the exit code");
eq(ctx.errorMessage("not json", "stderr text", 8), "stderr text",
"errorMessage uses stderr when stdout is not json");
console.log(`\n${checks} checks, ${failures} failures`);
process.exit(failures === 0 ? 0 : 1);