Files
curios/tools/harmony-ui.test.mjs
T

507 lines
18 KiB
JavaScript

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,
screenActive: false,
screenTarget: "",
screenError: null,
screenOpenWait: null,
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.screenWindowOpen", async ({target}) => {
if (model.screenOpenWait) await model.screenOpenWait;
if (model.screenError) throw model.screenError;
model.screenActive = true;
model.screenTarget = target;
return { active: true, target };
}),
handler("hdc.screenWindowStop", () => ({ active: (model.screenActive = false) })),
handler("hdc.screenWindowStatus", () => ({ active: model.screenActive, target: model.screenTarget, error: model.screenError })),
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 opens a separate window with default pointer control", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
assert.equal(model.screenActive, true);
assert.equal($("screen-start").textContent, "显示投屏窗口");
assert.match($("screen-status").textContent, /默认鼠标控制/);
assert.equal($("screen-stop").disabled, false);
assert.ok(!model.calls.includes("hdc.screenFrame"));
});
test("switching management tabs and hiding the page leaves the independent window open", async (t) => {
const { click, model, tick } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await click("tab-files");
window.document.dispatchEvent(new window.Event("visibilitychange"));
await tick();
assert.equal(model.screenActive, true);
assert.ok(!model.calls.includes("hdc.screenWindowStop"));
});
test("explicitly stopping a window permits reconnecting without a USB replug", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
await click("screen-stop");
assert.equal(model.screenActive, false);
assert.equal($("screen-start").disabled, false);
await click("screen-start");
assert.equal(model.screenActive, true);
});
test("closing the native window is reflected by the launcher", async (t) => {
const { $, click, model, tick } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
await click("screen-start");
model.screenActive = false;
await tick();
assert.equal($("screen-stop").disabled, true);
assert.equal($("screen-start").textContent, "打开投屏窗口");
});
test("a failed window start leaves the launcher available to retry", async (t) => {
const { $, click, model } = await app(t);
model.hdcAvailable = true;
await click("tab-management");
model.screenError = new Error("视频连接失败");
await click("screen-start");
assert.equal($("screen-start").disabled, false);
assert.equal($("screen-stop").disabled, true);
model.screenError = null;
await click("screen-start");
assert.equal(model.screenActive, true);
});