Files
insta360-link-tools/tests/test_joystick_logic.js
brad 82974f242d Never reuse a command string for Plasma's executable engine
Plasma's executable data engine keys its command containers by the command
string. DataEngine::connectSource calls requestSource, which returns a
pre-existing container when one matches, and then delivers that container's
cached data; the process itself only starts when the container is first
constructed. Re-issuing a byte-identical command therefore reports success,
with a plausible exit code and stdout, while running nothing at all.

That is precisely what the joystick produces. A stick held at full deflection
emits the same command every tick, so the camera would take one step and then
stop, with the widget showing no error because it received a successful
result. The same applies to a key held down, and to repeated presses of
Center or Home.

Every command now carries a trailing shell comment holding a counter, so the
engine never sees a string it has already cached. `#` starts a comment in sh,
and the commands already contain quotes so they always go through sh -c,
meaning the suffix changes the engine's key without changing what runs.

Resolution state moved from comparing the probe's command string to a plain
boolean, since the string is no longer stable across calls.

Tested: 65 node checks, including that successive sequence numbers differ and
that the suffix lands after a chained diagonal command. tests/test_resolver.sh
additionally runs the generated command through a real shell both with and
without the suffix and requires identical output, so the uniqueness trick
cannot quietly alter behaviour. Full suite of 172 checks across seven suites
passes, shellcheck clean, QML free of syntax errors.

This is a defect established by reading Plasma's source, not a confirmed
diagnosis of the reported fault. The two earlier theories for that fault, PATH
resolution and a stale pinned device node, were both ruled out by the
reporter: linkctl is installed from a pacman package into /usr/bin, and the
widget's device field was never set.
2026-10-07 23:10:02 -07:00

187 lines
9.2 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");
// --- uniqueify ---------------------------------------------------------
// Plasma's executable engine keys containers by command string and serves a
// pre-existing container's cached output instead of re-running, so repeating
// a command verbatim reports success without moving the camera.
eq(ctx.uniqueify("'linkctl' --force 'center'", 1),
"'linkctl' --force 'center' # 1", "uniqueify appends a comment");
eq(ctx.uniqueify("a", 1) === ctx.uniqueify("a", 2), false,
"successive sequence numbers differ");
// The suffix must be a shell comment so it cannot alter what runs.
eq(ctx.uniqueify("x", 7).includes(" # "), true, "suffix is a shell comment");
// A diagonal tick is two chained commands; the suffix must land at the end
// and so comment out nothing meaningful.
const tc = ctx.tickCommand(B, 1, 1, 6, false, false);
eq(ctx.uniqueify(tc, 3), tc + " # 3", "uniqueify appends after a chained command");
// --- 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);