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 = { attached: true, connection: null, calls: [], job: null, busy: false, monitorWait: null, }; const { createMockBridge } = await import( pathToFileURL(path.join(directory, "sdk/index.js")) ); const handler = (method, fn) => [ `@self.${method}`, async (params) => { model.calls.push(method); 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("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); });