返回 CodeWhale
backends.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / backends.test.mjs
1 // Backend tests that can run on any host: harmony logic via a mocked hdc
2 // exec, linux fail-closed probing, and module-shape checks for win32.
3 import { fileURLToPath } from "node:url";
4 import { test } from "node:test";
5 import assert from "node:assert/strict";
6 import fs from "node:fs";
7 import os from "node:os";
8 import path from "node:path";
9 import { parseBounds, flatten } from "../src/backends/harmonyos.mjs";
10
11 function fakeJpeg(w, h) {
12 // Minimal JPEG with an SOF0 marker carrying the dimensions.
13 return Buffer.from([
14 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, (h >> 8) & 0xff, h & 0xff,
15 (w >> 8) & 0xff, w & 0xff, 0x01, 0x01, 0x11, 0x00, 0xff, 0xd9,
16 ]);
17 }
18
19 function harmonyFixtureExec(t) {
20 const layout = {
21 attributes: { bundleName: "com.example.app", type: "FrameNode" },
22 children: [
23 {
24 attributes: { type: "Button", text: "OK", id: "ok_btn", bounds: "[100,200][300,260]" },
25 children: [],
26 },
27 {
28 attributes: { type: "Text", text: "Hello", bounds: "[0,0][100,50]" },
29 children: [{ attributes: { type: "Text", text: "nested", bounds: "[10,10][90,40]" }, children: [] }],
30 },
31 ],
32 };
33 const calls = [];
34 const exec = {
35 targetArgs: [],
36 run: async () => ({ code: 0, stdout: "", stderr: "" }),
37 runOk: async () => ({ code: 0, stdout: "", stderr: "" }),
38 shell: async (args) => { calls.push({ kind: "shell", args }); return { code: 0, stdout: "", stderr: "" }; },
39 async pullFile(remote, local) {
40 calls.push({ kind: "pull", remote, local });
41 if (remote.includes("layout")) fs.writeFileSync(local, Buffer.from(JSON.stringify(layout)));
42 else fs.writeFileSync(local, fakeJpeg(168, 120));
43 return local;
44 },
45 async readFile(remote) {
46 if (remote.includes("layout")) return Buffer.from(JSON.stringify(layout));
47 return fakeJpeg(168, 120);
48 },
49 };
50 return { exec, calls, layout };
51 }
52
53 test("harmony: parseBounds handles uitest bounds strings", () => {
54 assert.deepEqual(parseBounds("[100,200][300,260]"), { x: 100, y: 200, w: 200, h: 60, cx: 200, cy: 230 });
55 assert.equal(parseBounds("garbage"), null);
56 });
57
58 test("harmony: flatten produces indexed elements with paths and geometry", () => {
59 const tree = { attributes: { type: "root" }, children: [{ attributes: { type: "Button", text: "OK", bounds: "[0,0][10,10]" } }] };
60 const els = flatten(tree);
61 assert.equal(els[0].role, "root");
62 assert.equal(els[0].path.length, 0);
63 assert.equal(els[1].label, "OK");
64 assert.deepEqual(els[1].path, [0]);
65 assert.equal(els[1].bounds.cx, 5);
66 });
67
68 test("harmony: get_app_state flattens dumpLayout with indices and actions", async () => {
69 const { exec } = harmonyFixtureExec();
70 const mod = await import("../src/backends/harmonyos.mjs");
71 const b = mod.create({ exec });
72 const st = await b.get_app_state({});
73 assert.equal(st.bundle_id, "com.example.app");
74 assert.ok(st.elements.length >= 4);
75 const ok = st.elements.find((e) => e.label === "OK");
76 assert.ok(ok, "OK button flattened");
77 assert.deepEqual(ok.bounds, { x: 100, y: 200, w: 200, h: 60, cx: 200, cy: 230 });
78 assert.ok(ok.actions.includes("click"));
79 });
80
81 test("harmony: screenshot pulls the file and reports panel dimensions", async (t) => {
82 const { exec } = harmonyFixtureExec();
83 const mod = await import("../src/backends/harmonyos.mjs");
84 const b = mod.create({ exec });
85 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-hm-test-"));
86 const oldRec = process.env.CODEWHALE_CU_RECORDINGS_DIR; process.env.CODEWHALE_CU_RECORDINGS_DIR = dir;
87 t.after(() => { if (oldRec === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldRec; });
88 const shot = await b.screenshot({ path: path.join(dir, "shot.jpeg") });
89 assert.equal(shot.pixels.w, 168);
90 assert.equal(shot.pixels.h, 120);
91 assert.ok(fs.existsSync(shot.file));
92 fs.rmSync(dir, { recursive: true, force: true });
93 });
94
95 test("harmony: click routes through uitest uiInput with validated args", async () => {
96 const { exec, calls } = harmonyFixtureExec();
97 const mod = await import("../src/backends/harmonyos.mjs");
98 const b = mod.create({ exec });
99 const r = await b.left_click({ target: { x: 123.6, y: 45.2 } });
100 assert.equal(r.action_sent, true);
101 const ui = calls.find((c) => c.args[0] === "uitest");
102 assert.deepEqual(ui.args, ["uitest", "uiInput", "click", "124", "45"]);
103 });
104
105 test("harmony: element actions re-find the observed element and refuse a reordered tree", async () => {
106 const { exec, calls, layout } = harmonyFixtureExec();
107 const mod = await import("../src/backends/harmonyos.mjs");
108 const b = mod.create({ exec });
109 const ok = (await b.get_app_state({})).elements.find((e) => e.label === "OK");
110 const target = { index: ok.index, path: ok.path, role: ok.role, label: ok.label };
111 await b.perform_action({ target, action: "click" });
112 assert.deepEqual(calls.filter((c) => c.args?.[1] === "uiInput").at(-1).args, ["uitest", "uiInput", "click", "200", "230"]);
113 // A new sibling ahead of OK shifts every uitest index: the stored index now
114 // names "Delete". Nothing may be clicked.
115 layout.children.unshift({ attributes: { type: "Button", text: "Delete", bounds: "[0,300][100,360]" }, children: [] });
116 const before = calls.filter((c) => c.args?.[1] === "uiInput").length;
117 await assert.rejects(b.perform_action({ target, action: "click" }), (error) => error.code === "element_stale" && /label changed/.test(error.message));
118 await assert.rejects(b.set_value({ target, value: "x" }), (error) => error.code === "element_stale");
119 await assert.rejects(b.perform_action({ target: { index: ok.index }, action: "click" }), (error) => error.code === "element_stale", "a bare index has no identity to verify");
120 assert.equal(calls.filter((c) => c.args?.[1] === "uiInput").length, before, "no input after the tree changed");
121 });
122
123 test("harmony: clipboard and select_text fail closed with named reasons", async () => {
124 const { exec } = harmonyFixtureExec();
125 const mod = await import("../src/backends/harmonyos.mjs");
126 const b = mod.create({ exec });
127 await assert.rejects(() => b.read_clipboard(), /not exposed by hdc/);
128 await assert.rejects(() => b.select_text({}), /not exposed by uitest/);
129 assert.throws(() => b.key({ text: "cmd+c" }), /unsupported key/);
130 });
131
132 test("linux: probe reports the session and names missing tools (fail-closed)", async () => {
133 const mod = await import("../src/backends/linux.mjs");
134 const b = mod.create({ exec: (await import("../src/remote-runtime.mjs")).exec });
135 let p = null;
136 try {
137 p = await b.probe();
138 } catch (e) {
139 // No display session on this host: probe must fail closed with no_session.
140 assert.equal(e.code, "no_session");
141 return;
142 }
143 assert.equal(p.platform, "linux");
144 assert.ok(["x11", "wayland"].includes(p.session));
145 assert.equal(typeof p.missing.length, "number");
146 });
147
148 test("linux: probe rejects with no_session when no display session is visible", async () => {
149 const saved = {};
150 for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { saved[k] = process.env[k]; delete process.env[k]; }
151 try {
152 const mod = await import("../src/backends/linux.mjs");
153 const b = mod.create({ exec: (await import("../src/remote-runtime.mjs")).exec });
154 await assert.rejects(() => b.probe(), (e) => e.code === "no_session" && /DISPLAY/.test(e.message));
155 } finally {
156 for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { if (saved[k] !== undefined) process.env[k] = saved[k]; }
157 }
158 });
159
160 test("linux: open_application hands focus back unless activate:true", async () => {
161 const saved = {};
162 for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { saved[k] = process.env[k]; }
163 process.env.DISPLAY = "fixture";
164 delete process.env.WAYLAND_DISPLAY;
165 process.env.XDG_SESSION_TYPE = "x11";
166 try {
167 const mod = await import("../src/backends/linux.mjs");
168 const cmds = [];
169 const b = mod.create({ exec: {
170 have: async () => true,
171 run: async (cmd, args) => {
172 cmds.push([cmd, ...args].join(" "));
173 if (cmd === "xdotool" && args[0] === "getactivewindow") return { code: 0, stdout: "4242\n", stderr: "" };
174 return { code: 0, stdout: "", stderr: "" };
175 },
176 } });
177 // "true" is a real binary — the launch itself is harmless; the assertion is
178 // on the focus bookkeeping around it.
179 const bg = await b.open_application({ name: "true" });
180 assert.equal(bg.activate, false);
181 assert.equal(bg.focus_restored, true);
182 assert.ok(cmds.includes("xdotool getactivewindow"));
183 assert.ok(cmds.includes("xdotool windowactivate 4242"));
184 cmds.length = 0;
185 const fg = await b.open_application({ name: "true", activate: true });
186 assert.equal(fg.activate, true);
187 assert.ok(!cmds.some((c) => c.includes("windowactivate")), "activate:true must not touch focus bookkeeping");
188 } finally {
189 for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { if (saved[k] !== undefined) process.env[k] = saved[k]; else delete process.env[k]; }
190 }
191 });
192
193 test("win32: module loads with the full backend surface", async () => {
194 const mod = await import("../src/backends/win32.mjs");
195 assert.equal(typeof mod.create, "function");
196 if (process.platform !== "win32") {
197 const b = mod.create();
198 for (const m of ["probe", "screenshot", "left_click", "type", "key", "recordingStart", "get_app_state", "set_value"]) {
199 assert.equal(typeof b[m], "function", `win32 backend missing ${m}`);
200 }
201 }
202 });
203
204 test("remote agent refuses tools outside the allow-list", async () => {
205 const { run } = await import("../src/exec.mjs");
206 const sentinel = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cu-agent-deny-")), "x");
207 const payload = Buffer.from(JSON.stringify({ tool: "write_file", args: { path: sentinel } })).toString("base64");
208 const r = await run("node", [fileURLToPath(new URL("../agent.mjs", import.meta.url)), payload]);
209 const reply = JSON.parse(r.stdout.trim());
210 assert.equal(reply.ok, false);
211 assert.equal(reply.error.code, "tool_not_allowed");
212 assert.ok(!fs.existsSync(sentinel));
213 });
214
215 test("remote agent answers the platform probe", async () => {
216 const { run } = await import("../src/exec.mjs");
217 const payload = Buffer.from(JSON.stringify({ tool: "platform" })).toString("base64");
218 const r = await run("node", [fileURLToPath(new URL("../agent.mjs", import.meta.url)), payload]);
219 const reply = JSON.parse(r.stdout.trim());
220 assert.equal(reply.ok, true);
221 assert.equal(reply.platform, process.platform);
222 });
223
223 lines Plain Text