585 lines
20 KiB
JavaScript
585 lines
20 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,
|
||
screenSession: 0,
|
||
screenSequence: 0,
|
||
screenGeometry: 1,
|
||
screenControl: false,
|
||
screenFrameWait: null,
|
||
screenFrameError: 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.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;
|
||
return {
|
||
dataUrl: "data:image/jpeg;base64,/9j/2Q==",
|
||
width: 1200,
|
||
height: 2400,
|
||
captureMs: 300,
|
||
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 measured capture time", 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.*300 ms/);
|
||
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("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, /方向已变化/);
|
||
});
|