Files
curios/tools/harmony-ui.test.mjs
T
render 3ce918564d feat(harmony): stream screens over HDC without a companion app
Replace per-frame file capture with the unchanged official Hypium agent, bounded JPEG decoding, independent control RPC and scoped session cleanup. Remove the HAP build and account-signing experiment. All 85 automated checks pass; moving-screen performance and integrated phone input remain unverified because the USB device went offline. Keep published 0.3.2 artifacts unchanged.
2026-09-10 16:07:11 +08:00

647 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, cp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { setImmediate as flush } from "node:timers/promises";
import { JSDOM } from "jsdom";
const root = fileURLToPath(new URL("../", import.meta.url));
let cleanup;
afterEach(async () => {
if (cleanup) {
await cleanup();
cleanup = null;
}
});
async function settle() {
for (let i = 0; i < 6; i++) await flush();
}
async function app(t) {
t.mock.timers.enable({ apis: ["setTimeout"] });
const directory = await mkdtemp(path.join(tmpdir(), "harmony-ui-"));
await cp(path.join(root, "curios/harmony-connector/ui"), directory, {
recursive: true,
});
await cp(
path.dirname(fileURLToPath(import.meta.resolve("@trove/plugin-sdk"))),
path.join(directory, "sdk"),
{ recursive: true },
);
const dom = new JSDOM(
await readFile(path.join(directory, "index.html"), "utf8"),
{ url: "https://curio.test" },
);
globalThis.window = dom.window;
globalThis.document = dom.window.document;
const device = {
id: "7:3",
name: "daniel",
model: "HUAWEI Pura 70 Ultra",
manufacturer: "HUAWEI",
};
const storages = [{ id: 1, name: "内部存储", free: 1e9, capacity: 2e9 }];
const model = {
hdcAvailable: false,
hdcConnected: true,
confirmed: false,
parameters: [],
attached: true,
connection: null,
calls: [],
job: null,
busy: false,
monitorWait: null,
screenSession: 0,
screenSequence: 0,
screenGeometry: 1,
screenControl: false,
screenFrameWait: null,
screenFrameError: null,
screenUnchanged: false,
openedUrls: [],
selectedDirectory: "/fixture/folder",
configureError: null,
};
const { createMockBridge } = await import(
pathToFileURL(path.join(directory, "sdk/index.js"))
);
const handler = (method, fn) => [
`@self.${method}`,
async (params) => {
model.calls.push(method);
model.parameters.push([method, structuredClone(params)]);
return structuredClone(await fn(params));
},
];
window.__TROVE_CONTEXT__ = {
curioId: "dev.trove.harmony-connector",
version: "0.1.0",
instanceId: "test",
platform: "macos",
};
window.__TROVE_BRIDGE__ = createMockBridge(
Object.fromEntries([
handler("hdc.screenStart", () => ({
session: ++model.screenSession,
inputCapabilities: [
"click",
"doubleClick",
"longClick",
"swipe",
"drag",
"text",
"keyEvent",
],
control: false,
})),
handler("hdc.screenStop", () => {
model.screenControl = false;
return { stopped: true };
}),
handler("hdc.screenControl", ({ enabled }) => ({
control: (model.screenControl = enabled),
})),
handler("hdc.screenFrame", async () => {
if (model.screenFrameWait) await model.screenFrameWait;
if (model.screenFrameError) throw model.screenFrameError;
if (model.screenUnchanged && model.screenSequence)
return {
unchanged: true,
width: 1200,
height: 2400,
sequence: model.screenSequence,
geometry: model.screenGeometry,
control: model.screenControl,
receivedFps: 0,
mbps: 0,
};
return {
dataUrl: "data:image/jpeg;base64,/9j/2Q==",
width: 1200,
height: 2400,
receivedFps: 27,
mbps: 3.2,
sequence: ++model.screenSequence,
geometry: model.screenGeometry,
control: model.screenControl,
};
}),
handler("hdc.screenInput", () => ({ executed: true })),
handler("hdc.screenSave", () => ({ path: "/fixture/screen.jpg" })),
handler("hdc.status", () => ({ busy: false, job: null })),
handler("hdc.tool", () => ({
available: model.hdcAvailable,
version: "HDC test",
path: "/fixture/hdc",
})),
handler("hdc.configure", () => {
if (model.configureError) throw model.configureError;
model.hdcAvailable = true;
return { available: true, version: "HDC test", path: "/fixture/hdc" };
}),
[
"host.runtime.openUrl",
({ url }) => {
model.openedUrls.push(url);
return { ok: true };
},
],
handler("hdc.devices", () => ({
devices: model.hdcConnected
? [{ id: "phone", transport: "USB", state: "Connected" }]
: [],
})),
handler("hdc.inspect", () => ({
capabilities: Object.fromEntries(
["apps", "launch", "logs", "report", "screenshot"].map((key) => [
key,
{ available: true },
]),
),
info: { text: "Test device" },
})),
handler("hdc.apps", () => ({ apps: ["com.example.app"] })),
handler("hdc.app", () => ({
bundle: "com.example.app",
versionCode: 1,
versionName: "1.0",
debug: false,
abilities: [{ name: "Home", module: "entry" }],
details: {},
})),
handler("hdc.package", () => ({
bundle: "com.example.app",
versionCode: 2,
versionName: "2.0",
sha256: "confirmed-sha256",
size: 100,
})),
handler("hdc.start", () => ({ id: 9 })),
["host.dialog.confirm", () => ({ confirmed: model.confirmed })],
["host.dialog.openDirectory", () => ({ path: model.selectedDirectory })],
handler("info", () => ({ connection: model.connection, busy: false })),
handler("devices", () => ({ devices: model.attached ? [device] : [] })),
handler("connect", () => (model.connection = { device, storages })),
handler("disconnect", () => {
model.connection = null;
return { disconnected: true, busy: model.busy };
}),
handler("list", () => ({
entries: [
{
id: 2,
parent: 0xffffffff,
storage: 1,
name: "旅行.pdf",
size: 100,
folder: false,
},
],
total: 1,
})),
handler("transferStatus", () => model.job),
handler("transfer", () => {
model.job = { id: 1, state: "running", sent: 0, total: 100 };
return { id: 1 };
}),
["host.dialog.openFile", () => ({ path: "/fixture/file.txt" })],
handler("monitor", async () => {
const snapshot = {
connection: model.connection,
devices: model.attached ? [device] : [],
busy: model.busy,
};
if (!model.attached) model.connection = null;
if (model.monitorWait) {
await model.monitorWait;
return snapshot;
}
return { ...snapshot, connection: model.connection };
}),
]),
);
cleanup = async () => {
dom.window.dispatchEvent(new dom.window.Event("pagehide"));
dom.window.close();
delete globalThis.window;
delete globalThis.document;
await rm(directory, { recursive: true, force: true });
};
await import(pathToFileURL(path.join(directory, "app.js")));
await settle();
const $ = (id) => document.getElementById(id);
const click = async (id) => {
$(id).click();
await settle();
};
const tick = async () => {
t.mock.timers.tick(2500);
await settle();
};
const connect = async () => {
await click("scan");
await click("connect");
assert.equal($("browser").hidden, false);
};
return { $, click, tick, connect, model };
}
test("opening the plugin discovers already attached devices without clicking scan", async (t) => {
const { $ } = await app(t);
assert.equal($("device-picker").hidden, false);
assert.equal($("device-select").options.length, 1);
});
test("plugging in and unplugging refreshes the picker without manual scanning", async (t) => {
const { $, tick, model } = await app(t);
model.attached = false;
await tick();
assert.equal($("device-picker").hidden, true);
model.attached = true;
await tick();
assert.equal($("device-picker").hidden, false);
model.attached = false;
await tick();
assert.equal($("device-select").options.length, 0);
});
test("unplugging an idle connected phone clears the browser and connected badge", async (t) => {
const { $, connect, tick, model } = await app(t);
await connect();
model.attached = false;
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("device-dot").classList.contains("connected"), false);
assert.equal($("files").children.length, 0);
assert.equal($("device-select").options.length, 0);
});
test("manual disconnect clears files, filters and completed transfers", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.job = {
id: 1,
state: "completed",
result: { name: "旅行.pdf", bytes: 100 },
};
await tick();
assert.equal($("transfer").hidden, false);
$("search").value = "旅行";
await click("disconnect");
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
assert.equal($("search").value, "");
assert.equal($("transfer").hidden, true);
assert.equal($("footer-status").textContent.includes("已连接"), false);
});
test("unplugging after a completed transfer clears the old success panel", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.job = {
id: 1,
state: "completed",
result: { name: "旅行.pdf", bytes: 100 },
};
await tick();
assert.equal($("transfer").hidden, false);
model.attached = false;
await tick();
assert.equal($("transfer").hidden, true);
});
test("connected device displays the friendly name and actual model separately", async (t) => {
const { $, connect } = await app(t);
await connect();
assert.equal($("device-name").textContent, "daniel");
assert.equal($("device-model").textContent, "HUAWEI Pura 70 Ultra");
assert.equal($("device-model").hidden, false);
});
test("an in-flight USB snapshot cannot restore a manually disconnected session", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
let resolve;
model.monitorWait = new Promise((done) => {
resolve = done;
});
await tick();
const disconnect = click("disconnect");
resolve();
await disconnect;
await settle();
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
});
test("closing the UI stops automatic scanning", async (t) => {
const { tick, model } = await app(t);
const before = model.calls.length;
window.dispatchEvent(new window.Event("pagehide"));
await tick();
await tick();
assert.equal(model.calls.length, before);
});
test("USB disappearance during a transfer clears the browser while cleanup is pending", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.attached = false;
model.busy = true;
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("device-dot").classList.contains("connected"), false);
assert.equal($("scan").disabled, true);
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
});
test("manual disconnect keeps the UI responsive while USB cleanup is pending", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
model.busy = true;
const scans = model.calls.filter((method) => method === "devices").length;
await click("disconnect");
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
assert.equal($("scan").disabled, true);
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
assert.equal(
model.calls.filter((method) => method === "devices").length,
scans,
);
model.busy = false;
await tick();
assert.equal($("connect").disabled, false);
assert.equal($("browser").hidden, true);
});
test("whole folder upload uses the directory picker and exposes transfer metrics", async (t) => {
const { $, click, connect, model, tick } = await app(t);
await connect();
await click("upload-folder");
assert.equal(
model.parameters.find(([name]) => name === "transfer")[1].path,
"/fixture/folder",
);
model.job.statistics = {
bytesPerSecond: 1024,
averageBytesPerSecond: 1024,
elapsedSeconds: 2,
remainingSeconds: 3,
filesCompleted: 1,
filesTotal: 4,
currentFile: "nested/data.bin",
};
await tick();
assert.match($("transfer-current").textContent, /nested\/data.bin/);
assert.match($("transfer-metrics").textContent, /KB\/s/);
});
test("management is optional and missing HDC does not disable file transfers", async (t) => {
const { $, click, connect, model } = await app(t);
assert.ok(!model.calls.some((name) => name.startsWith("hdc.")));
await click("tab-management");
assert.match($("hdc-tool-status").textContent, /未发现/);
assert.equal($("app-install").disabled, true);
await click("tab-files");
await connect();
assert.equal($("browser").hidden, false);
});
test("missing HDC guides official download and configuring an extracted toolkit", async (t) => {
const { $, click, model } = await app(t);
await click("tab-management");
assert.equal($("hdc-setup")?.hidden, false);
assert.match($("hdc-setup").textContent, /Mac.*ARM64/);
assert.match($("hdc-setup").textContent, /解压/);
assert.match($("hdc-setup").textContent.replace(/\s+/g, " "), /USB 调试/);
await click("hdc-download");
assert.deepEqual(model.openedUrls, [
"https://developer.huawei.com/consumer/cn/download/command-line-tools-for-hmos",
]);
assert.equal($("hdc-setup").hidden, false);
assert.ok(!model.calls.includes("hdc.devices"));
await click("hdc-directory");
assert.deepEqual(
model.parameters.find(([name]) => name === "hdc.configure")[1],
{
path: "/fixture/folder",
},
);
assert.equal($("hdc-setup").hidden, true);
assert.equal($("hdc-target").options.length, 1);
});
test("refresh rechecks tool availability and clears stale devices if the tool disappears", async (t) => {
const { $, click, model } = await app(t);
await click("tab-management");
await click("hdc-refresh");
assert.ok(!model.calls.includes("hdc.devices"));
model.hdcAvailable = true;
await click("hdc-refresh");
assert.equal($("hdc-setup").hidden, true);
assert.equal($("screen-start").disabled, false);
model.hdcAvailable = false;
await click("hdc-refresh");
assert.equal($("hdc-setup").hidden, false);
assert.equal($("hdc-target").options.length, 0);
assert.equal($("screen-start").disabled, true);
});
test("cancelled or invalid toolkit selection preserves installation guidance", async (t) => {
const { $, click, model } = await app(t);
await click("tab-management");
model.selectedDirectory = null;
await click("hdc-directory");
assert.ok(!model.calls.includes("hdc.configure"));
model.selectedDirectory = "/fixture/wrong";
model.configureError = new Error("未找到 HDC,请选择解压后的工具目录。");
await click("hdc-directory");
assert.equal($("hdc-setup").hidden, false);
assert.ok(!model.calls.includes("hdc.devices"));
assert.match(document.body.textContent, /未找到 HDC/);
});
test("application installation requires confirmation and pins the reviewed package hash", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("app-install");
assert.ok(!model.calls.includes("hdc.start"));
model.confirmed = true;
await click("app-install");
const params = model.parameters.find(([name]) => name === "hdc.start")[1];
assert.deepEqual(params, {
kind: "install",
target: "phone",
path: "/fixture/file.txt",
sha256: "confirmed-sha256",
});
assert.equal($("app-install").disabled, true);
});
test("device disconnect clears app details and prevents stale target operations", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
$("apps-list").querySelector("button").click();
await settle();
assert.equal($("app-detail").hidden, false);
assert.equal($("app-export").disabled, true);
model.hdcConnected = false;
t.mock.timers.tick(3500);
await settle();
assert.equal($("app-detail").hidden, true);
assert.equal($("app-install").disabled, true);
assert.equal($("app-launch").disabled, true);
});
test("screen viewing starts read-only, keeps refreshing and reports stream throughput", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
assert.equal($("screen-start").disabled, false);
await click("screen-start");
await tick();
assert.equal($("screen-image").hidden, false);
assert.match($("screen-status").textContent, /仅观看/);
assert.match(
$("screen-stats").textContent,
/1200 × 2400.*接收 27 帧\/秒.*3.2 Mbps/,
);
assert.equal($("screen-control").checked, false);
assert.equal($("app-install").disabled, true);
const before = model.screenSequence;
await tick();
assert.ok(model.screenSequence > before);
assert.ok(!model.calls.includes("hdc.screenInput"));
});
test("screen text and navigation require enabled control and use the displayed session", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await tick();
await click("screen-control");
$("screen-text").value = "你好,daniel's phone";
$("screen-text").dispatchEvent(new window.Event("input"));
await click("screen-send-text");
await tick();
const sent = model.parameters.find(([name]) => name === "hdc.screenInput")[1];
assert.deepEqual(sent, {
session: 1,
sequence: 1,
geometry: 1,
input: { kind: "text", text: "你好,daniel's phone" },
});
await click("screen-stop");
assert.equal($("screen-image").hidden, true);
assert.equal($("screen-image").hasAttribute("src"), false);
assert.equal($("screen-text").disabled, true);
assert.equal($("app-install").disabled, false);
});
test("an unchanged live screen preserves its image and does not count repeated frames", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await tick();
await click("screen-control");
const source = $("screen-image").src;
const sequence = model.screenSequence;
model.screenUnchanged = true;
await tick();
await tick();
assert.equal($("screen-image").src, source);
assert.equal($("screen-image").hidden, false);
assert.equal(model.screenSequence, sequence);
assert.equal($("screen-control").checked, true);
assert.match($("screen-stats").textContent, /接收 0 帧\/秒/);
assert.ok(!model.calls.includes("hdc.screenStop"));
});
test("control does not wait behind a pending video frame", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await tick();
await click("screen-control");
let release;
model.screenFrameWait = new Promise((resolve) => {
release = resolve;
});
await tick();
$("screen-inputs").querySelector('[data-screen-key="home"]').click();
await settle();
assert.ok(
model.parameters.some(
([name, params]) =>
name === "hdc.screenInput" && params.input.key === "home",
),
);
release();
model.screenFrameWait = null;
await settle();
await click("screen-stop");
});
test("stopping during a pending frame discards that response and permits a fresh screen session", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
let release;
model.screenFrameWait = new Promise((resolve) => {
release = resolve;
});
await click("screen-start");
await tick();
await click("screen-stop");
assert.equal($("screen-image").hidden, true);
assert.ok(model.calls.includes("hdc.screenStop"));
release();
model.screenFrameWait = null;
await settle();
assert.equal($("screen-image").hasAttribute("src"), false);
await click("screen-start");
await tick();
assert.equal(model.screenSession, 2);
assert.equal($("screen-image").hidden, false);
});
test("capture failure clears the screen and queued input instead of showing an old live frame", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await tick();
model.screenFrameError = new Error("Device disconnected");
await tick();
assert.equal($("screen-image").hidden, true);
assert.match($("screen-status").textContent, /未开始/);
assert.equal($("screen-control").checked, false);
});
test("leaving management stops refresh and control; returning does not resume them", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await tick();
await click("screen-control");
await click("tab-files");
const before = model.screenSequence;
await tick();
await click("tab-management");
await tick();
assert.equal(model.screenSequence, before);
assert.equal($("screen-image").hidden, true);
assert.equal($("screen-control").checked, false);
});
test("rotation disables control and clears a gesture waiting to be sent", async (t) => {
const { $, click, tick, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await tick();
await click("screen-control");
model.screenGeometry++;
model.screenControl = false;
await tick();
assert.equal($("screen-control").checked, false);
assert.match($("screen-input-status").textContent, /方向已变化/);
});