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.
187 lines
9.2 KiB
JavaScript
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);
|