| 1 | // Platform command tests use injected runners, never a real Windows/Linux GUI. |
| 2 | import { test } from "node:test"; |
| 3 | import assert from "node:assert/strict"; |
| 4 | import windows from "../src/backends/win32.mjs"; |
| 5 | import linux from "../src/backends/linux.mjs"; |
| 6 | import { currentSignal, withSignal } from "../src/exec.mjs"; |
| 7 | |
| 8 | const success = { code: 0, stdout: '{"ok":true}', stderr: "", timedOut: false }; |
| 9 | const decode = (args) => Buffer.from(args[args.indexOf("-EncodedCommand") + 1], "base64").toString("utf16le"); |
| 10 | |
| 11 | for (const [name, invoke, release] of [ |
| 12 | ["key hold", (backend) => backend.hold_key({ text: "ctrl+a", duration: 30 }), /SendKey\(65, 2\).*SendKey\(17, 2\)/s], |
| 13 | ["drag", (backend) => backend.left_click_drag({ from_target: { x: 1, y: 2 }, to: { x: 30, y: 40 } }), /mouse_event\(\[User32\]::LEFTUP/], |
| 14 | ["right click", (backend) => backend.right_click({ target: { x: 1, y: 2 } }), /mouse_event\(\[User32\]::RIGHTUP/], |
| 15 | ]) { |
| 16 | test(`Windows ${name} cancellation releases the owned input in an uncancelled process`, async () => { |
| 17 | const controller = new AbortController(); |
| 18 | const calls = []; |
| 19 | const backend = windows.create({ exec: { persistentInputOwner: true, run: async (_cmd, args) => { |
| 20 | calls.push({ script: decode(args), signal: currentSignal() }); |
| 21 | if (calls.length === 1) { |
| 22 | controller.abort(); |
| 23 | return { ...success, code: null, aborted: true }; |
| 24 | } |
| 25 | return success; |
| 26 | } } }); |
| 27 | await assert.rejects(withSignal(controller.signal, () => invoke(backend)), (err) => err.code === "cancelled"); |
| 28 | assert.equal(calls.length, 2); |
| 29 | assert.equal(calls[1].signal, null, "cleanup must survive the cancelled request"); |
| 30 | assert.match(calls[1].script, release); |
| 31 | await backend.releaseInput(); |
| 32 | assert.equal(calls.length, 2, "released input is no longer owned"); |
| 33 | }); |
| 34 | } |
| 35 | |
| 36 | test("Windows session cleanup releases a completed mouse-down without releasing another session", async () => { |
| 37 | const calls = []; |
| 38 | const create = () => windows.create({ exec: { persistentInputOwner: true, run: async (_cmd, args) => { calls.push(decode(args)); return success; } } }); |
| 39 | const owner = create(); |
| 40 | const other = create(); |
| 41 | await owner.left_mouse_down({ target: { x: 1, y: 2 } }); |
| 42 | await owner.key({ text: "a" }); |
| 43 | await other.releaseInput(); |
| 44 | assert.equal(calls.length, 2, "a normal key and another session's cleanup retain the owned mouse-down"); |
| 45 | await owner.releaseInput(); |
| 46 | assert.equal(calls.length, 3); |
| 47 | assert.match(calls.at(-1), /mouse_event\(\[User32\]::LEFTUP/); |
| 48 | }); |
| 49 | |
| 50 | function fakeLinux(t, kind, onRun) { |
| 51 | const saved = Object.fromEntries(["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"].map((key) => [key, process.env[key]])); |
| 52 | delete process.env.DISPLAY; |
| 53 | delete process.env.WAYLAND_DISPLAY; |
| 54 | process.env.XDG_SESSION_TYPE = kind; |
| 55 | process.env[kind === "x11" ? "DISPLAY" : "WAYLAND_DISPLAY"] = "test-only"; |
| 56 | t.after(() => { for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); |
| 57 | const calls = []; |
| 58 | const backend = linux.create({ exec: { |
| 59 | persistentInputOwner: true, |
| 60 | have: async () => true, |
| 61 | run: async (cmd, args) => { |
| 62 | const call = { cmd, args, signal: currentSignal() }; |
| 63 | calls.push(call); |
| 64 | return await onRun?.(call) ?? success; |
| 65 | }, |
| 66 | } }); |
| 67 | return { backend, calls }; |
| 68 | } |
| 69 | |
| 70 | test("X11 cancelled hold and drag release their keys/buttons with cancellation disabled", async (t) => { |
| 71 | let controller = new AbortController(); |
| 72 | const { backend, calls } = fakeLinux(t, "x11", ({ args }) => { |
| 73 | if (["keydown", "mousedown"].includes(args[0])) controller.abort(); |
| 74 | }); |
| 75 | await assert.rejects(withSignal(controller.signal, () => backend.hold_key({ text: "ctrl+a", duration: 30 })), (err) => err.code === "cancelled"); |
| 76 | assert.deepEqual(calls.at(-1).args, ["keyup", "ctrl+a"]); |
| 77 | assert.equal(calls.at(-1).signal, null); |
| 78 | controller = new AbortController(); |
| 79 | await assert.rejects(withSignal(controller.signal, () => backend.left_click_drag({ from_target: { x: 1, y: 2 }, to: { x: 20, y: 30 } })), (err) => err.code === "cancelled"); |
| 80 | assert.deepEqual(calls.at(-1).args, ["mouseup", "1"]); |
| 81 | assert.equal(calls.at(-1).signal, null); |
| 82 | }); |
| 83 | |
| 84 | test("X11 targeted mouse-down initializes input, aims, and retains release ownership", async (t) => { |
| 85 | const { backend, calls } = fakeLinux(t, "x11"); |
| 86 | await backend.left_mouse_down({ target: { x: 12, y: 34 } }); |
| 87 | assert.deepEqual(calls.slice(-2).map((call) => call.args), [["mousemove", "--sync", "12", "34"], ["mousedown", "1"]]); |
| 88 | await backend.releaseInput(); |
| 89 | assert.deepEqual(calls.at(-1).args, ["mouseup", "1"]); |
| 90 | const count = calls.length; |
| 91 | await backend.releaseInput(); |
| 92 | assert.equal(calls.length, count); |
| 93 | }); |
| 94 | |
| 95 | test("Wayland hold and repeated shortcuts use one complete temporary keyboard gesture", async (t) => { |
| 96 | const { backend, calls } = fakeLinux(t, "wayland"); |
| 97 | await backend.hold_key({ text: "ctrl+left", duration: 2 }); |
| 98 | assert.deepEqual(calls.at(-1).args, ["-M", "ctrl", "-P", "Left", "-s", "2000", "-p", "Left", "-m", "ctrl"]); |
| 99 | await backend.key({ text: "alt+tab", repeat: 2 }); |
| 100 | assert.deepEqual(calls.at(-1).args, ["-M", "alt", "-P", "Tab", "-p", "Tab", "-P", "Tab", "-p", "Tab", "-m", "alt"]); |
| 101 | assert.equal(calls.filter((call) => call.cmd === "wtype").length, 2); |
| 102 | assert.ok(!calls.some((call) => call.cmd === "ydotool")); |
| 103 | }); |
| 104 | |
| 105 | test("Wayland child cancellation is reported as cancelled instead of success", async (t) => { |
| 106 | const controller = new AbortController(); |
| 107 | const { backend } = fakeLinux(t, "wayland", ({ cmd }) => { |
| 108 | if (cmd === "wtype") { controller.abort(); return { ...success, code: null, aborted: true }; } |
| 109 | }); |
| 110 | await assert.rejects(withSignal(controller.signal, () => backend.hold_key({ text: "shift", duration: 30 })), (err) => err.code === "cancelled"); |
| 111 | }); |
| 112 | |
| 113 | for (const [name, platform] of [["Windows", windows], ["Linux", linux]]) { |
| 114 | test(`${name} refuses recording before spawning an unowned recorder`, async () => { |
| 115 | const calls = []; |
| 116 | const backend = platform.create({ exec: { persistentInputOwner: true, run: async (...args) => { calls.push(args); return success; } } }); |
| 117 | await assert.rejects(backend.recordingStart({ fps: 15 }), (err) => err.code === "owned_recording_unavailable"); |
| 118 | assert.deepEqual(calls, []); |
| 119 | assert.deepEqual(await backend.recordingStatus({ id: "not-started" }), { id: "not-started", running: false }); |
| 120 | }); |
| 121 | |
| 122 | test(`${name} direct mode refuses held gestures before moving or pressing`, async () => { |
| 123 | const calls = []; |
| 124 | const backend = platform.create({ exec: { run: async (...args) => { calls.push(args); return success; } } }); |
| 125 | for (const [tool, args] of [ |
| 126 | ["left_mouse_down", { target: { x: 1, y: 2 } }], |
| 127 | ["left_click_drag", { from_target: { x: 1, y: 2 }, to: { x: 3, y: 4 } }], |
| 128 | ["hold_key", { text: "shift", duration: 1 }], |
| 129 | ]) await assert.rejects(backend[tool](args), (err) => err.code === "input_owner_required"); |
| 130 | await assert.rejects(backend.left_mouse_up(), (err) => err.code === "input_not_held"); |
| 131 | await backend.releaseInput(); |
| 132 | assert.deepEqual(calls, [], "no command or unrelated release is sent without an input owner"); |
| 133 | }); |
| 134 | } |
| 135 |