返回 CodeWhale
input-cancellation.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / input-cancellation.test.mjs
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
135 lines Plain Text