| 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 }; |
| 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 () => { |
| 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 shot = await b.screenshot({ path: path.join(dir, "shot.jpeg") }); |
| 87 | assert.equal(shot.pixels.w, 168); |
| 88 | assert.equal(shot.pixels.h, 120); |
| 89 | assert.ok(fs.existsSync(shot.file)); |
| 90 | fs.rmSync(dir, { recursive: true, force: true }); |
| 91 | }); |
| 92 | |
| 93 | test("harmony: click routes through uitest uiInput with validated args", async () => { |
| 94 | const { exec, calls } = harmonyFixtureExec(); |
| 95 | const mod = await import("../src/backends/harmonyos.mjs"); |
| 96 | const b = mod.create({ exec }); |
| 97 | const r = await b.left_click({ target: { x: 123.6, y: 45.2 } }); |
| 98 | assert.equal(r.action_sent, true); |
| 99 | const ui = calls.find((c) => c.args[0] === "uitest"); |
| 100 | assert.deepEqual(ui.args, ["uitest", "uiInput", "click", "124", "45"]); |
| 101 | }); |
| 102 | |
| 103 | test("harmony: clipboard and select_text fail closed with named reasons", async () => { |
| 104 | const { exec } = harmonyFixtureExec(); |
| 105 | const mod = await import("../src/backends/harmonyos.mjs"); |
| 106 | const b = mod.create({ exec }); |
| 107 | await assert.rejects(() => b.read_clipboard(), /not exposed by hdc/); |
| 108 | await assert.rejects(() => b.select_text({}), /not exposed by uitest/); |
| 109 | assert.throws(() => b.key({ text: "cmd+c" }), /unsupported key/); |
| 110 | }); |
| 111 | |
| 112 | test("linux: probe reports the session and names missing tools (fail-closed)", async () => { |
| 113 | const mod = await import("../src/backends/linux.mjs"); |
| 114 | const b = mod.create({ exec: (await import("../src/remote-runtime.mjs")).exec }); |
| 115 | let p = null; |
| 116 | try { |
| 117 | p = await b.probe(); |
| 118 | } catch (e) { |
| 119 | // No display session on this host: probe must fail closed with no_session. |
| 120 | assert.equal(e.code, "no_session"); |
| 121 | return; |
| 122 | } |
| 123 | assert.equal(p.platform, "linux"); |
| 124 | assert.ok(["x11", "wayland"].includes(p.session)); |
| 125 | assert.equal(typeof p.missing.length, "number"); |
| 126 | }); |
| 127 | |
| 128 | test("linux: probe rejects with no_session when no display session is visible", async () => { |
| 129 | const saved = {}; |
| 130 | for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { saved[k] = process.env[k]; delete process.env[k]; } |
| 131 | try { |
| 132 | const mod = await import("../src/backends/linux.mjs"); |
| 133 | const b = mod.create({ exec: (await import("../src/remote-runtime.mjs")).exec }); |
| 134 | await assert.rejects(() => b.probe(), (e) => e.code === "no_session" && /DISPLAY/.test(e.message)); |
| 135 | } finally { |
| 136 | for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { if (saved[k] !== undefined) process.env[k] = saved[k]; } |
| 137 | } |
| 138 | }); |
| 139 | |
| 140 | test("linux: open_application hands focus back unless activate:true", async () => { |
| 141 | const saved = {}; |
| 142 | for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { saved[k] = process.env[k]; } |
| 143 | process.env.DISPLAY = "fixture"; |
| 144 | delete process.env.WAYLAND_DISPLAY; |
| 145 | process.env.XDG_SESSION_TYPE = "x11"; |
| 146 | try { |
| 147 | const mod = await import("../src/backends/linux.mjs"); |
| 148 | const cmds = []; |
| 149 | const b = mod.create({ exec: { |
| 150 | have: async () => true, |
| 151 | run: async (cmd, args) => { |
| 152 | cmds.push([cmd, ...args].join(" ")); |
| 153 | if (cmd === "xdotool" && args[0] === "getactivewindow") return { code: 0, stdout: "4242\n", stderr: "" }; |
| 154 | return { code: 0, stdout: "", stderr: "" }; |
| 155 | }, |
| 156 | } }); |
| 157 | // "true" is a real binary — the launch itself is harmless; the assertion is |
| 158 | // on the focus bookkeeping around it. |
| 159 | const bg = await b.open_application({ name: "true" }); |
| 160 | assert.equal(bg.activate, false); |
| 161 | assert.equal(bg.focus_restored, true); |
| 162 | assert.ok(cmds.includes("xdotool getactivewindow")); |
| 163 | assert.ok(cmds.includes("xdotool windowactivate 4242")); |
| 164 | cmds.length = 0; |
| 165 | const fg = await b.open_application({ name: "true", activate: true }); |
| 166 | assert.equal(fg.activate, true); |
| 167 | assert.ok(!cmds.some((c) => c.includes("windowactivate")), "activate:true must not touch focus bookkeeping"); |
| 168 | } finally { |
| 169 | for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"]) { if (saved[k] !== undefined) process.env[k] = saved[k]; else delete process.env[k]; } |
| 170 | } |
| 171 | }); |
| 172 | |
| 173 | test("win32: module loads with the full backend surface", async () => { |
| 174 | const mod = await import("../src/backends/win32.mjs"); |
| 175 | assert.equal(typeof mod.create, "function"); |
| 176 | if (process.platform !== "win32") { |
| 177 | const b = mod.create(); |
| 178 | for (const m of ["probe", "screenshot", "left_click", "type", "key", "recordingStart", "get_app_state", "set_value"]) { |
| 179 | assert.equal(typeof b[m], "function", `win32 backend missing ${m}`); |
| 180 | } |
| 181 | } |
| 182 | }); |
| 183 | |
| 184 | test("remote agent refuses tools outside the allow-list", async () => { |
| 185 | const { run } = await import("../src/exec.mjs"); |
| 186 | const sentinel = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cu-agent-deny-")), "x"); |
| 187 | const payload = Buffer.from(JSON.stringify({ tool: "write_file", args: { path: sentinel } })).toString("base64"); |
| 188 | const r = await run("node", [fileURLToPath(new URL("../agent.mjs", import.meta.url)), payload]); |
| 189 | const reply = JSON.parse(r.stdout.trim()); |
| 190 | assert.equal(reply.ok, false); |
| 191 | assert.equal(reply.error.code, "tool_not_allowed"); |
| 192 | assert.ok(!fs.existsSync(sentinel)); |
| 193 | }); |
| 194 | |
| 195 | test("remote agent answers the platform probe", async () => { |
| 196 | const { run } = await import("../src/exec.mjs"); |
| 197 | const payload = Buffer.from(JSON.stringify({ tool: "platform" })).toString("base64"); |
| 198 | const r = await run("node", [fileURLToPath(new URL("../agent.mjs", import.meta.url)), payload]); |
| 199 | const reply = JSON.parse(r.stdout.trim()); |
| 200 | assert.equal(reply.ok, true); |
| 201 | assert.equal(reply.platform, process.platform); |
| 202 | }); |
| 203 |