fix(harmony): discover devices and release disconnected sessions asynchronously
Use USB descriptors and MTP identity instead of the static libmtp product label. Monitor attachment, discard stale UI responses, clear disconnected views, and release slow MTP sessions in the background while preserving operation serialization. Publish 0.1.1 with immutable source history. Cover discovery, names, unplugging and slow disconnect with native and UI regressions. Verify real Pura 70 Ultra identity and prompt disconnect; run the newest published backend during Git-source checks.
This commit is contained in:
@@ -3,13 +3,15 @@ import { createMockBridge } from "./sdk/index.js";
|
||||
const root = 0xffffffff;
|
||||
const device = {
|
||||
id: "fixture",
|
||||
name: "HarmonyOS 6 · 界面测试",
|
||||
name: "daniel",
|
||||
model: "HUAWEI Pura 70 Ultra",
|
||||
manufacturer: "模拟设备",
|
||||
};
|
||||
const storages = [
|
||||
{ id: 1, name: "内部存储", capacity: 256 * 1024 ** 3, free: 128 * 1024 ** 3 },
|
||||
];
|
||||
let connection = null,
|
||||
let attached = true,
|
||||
connection = null,
|
||||
job = null,
|
||||
next = 30;
|
||||
const entries = [
|
||||
@@ -61,16 +63,22 @@ const entries = [
|
||||
];
|
||||
window.__TROVE_CONTEXT__ = {
|
||||
curioId: "dev.trove.harmony-connector",
|
||||
version: "0.1.0",
|
||||
version: "0.1.1",
|
||||
instanceId: "ui-fixture",
|
||||
platform: "macos",
|
||||
};
|
||||
window.__TROVE_BRIDGE__ = createMockBridge({
|
||||
"@self.info": () => ({ connection, busy: false }),
|
||||
"@self.devices": () => ({ devices: [device] }),
|
||||
"@self.devices": () => ({ devices: attached ? [device] : [] }),
|
||||
"@self.monitor": () => ({
|
||||
connection,
|
||||
devices: attached ? [device] : [],
|
||||
busy: job?.state === "running",
|
||||
}),
|
||||
"@self.connect": () => (connection = { device, storages }),
|
||||
"@self.disconnect": () => {
|
||||
connection = null;
|
||||
job = null;
|
||||
return { disconnected: true };
|
||||
},
|
||||
"@self.list": ({ parent, offset = 0 }) => {
|
||||
@@ -117,4 +125,20 @@ badge.textContent = "界面测试 · 模拟设备,不执行真实文件传输"
|
||||
badge.style.cssText =
|
||||
"position:fixed;bottom:3px;left:240px;z-index:10;background:#805800;color:white;padding:4px 8px;border-radius:5px;font-size:10px";
|
||||
document.body.append(badge);
|
||||
const toggle = document.createElement("button");
|
||||
toggle.textContent = "模拟拔掉 USB";
|
||||
toggle.style.cssText =
|
||||
"position:fixed;bottom:30px;left:16px;z-index:10;background:#805800;color:white";
|
||||
toggle.addEventListener("click", () => {
|
||||
attached = !attached;
|
||||
if (!attached) {
|
||||
connection = null;
|
||||
if (job?.state === "running") {
|
||||
job.state = "failed";
|
||||
job.error = { message: "设备已断开(模拟)" };
|
||||
}
|
||||
}
|
||||
toggle.textContent = attached ? "模拟拔掉 USB" : "模拟接入 USB";
|
||||
});
|
||||
document.body.append(toggle);
|
||||
await import("./app.js");
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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);
|
||||
});
|
||||
+3
-1
@@ -520,7 +520,9 @@ async fn remote_source_check(url: &str, source_id: &str, kind: &str) -> Result<(
|
||||
.await;
|
||||
if rows.iter().all(|row| row["status"] == "pass") {
|
||||
for entry in packages.catalog() {
|
||||
for version in entry.package.versions {
|
||||
// Our publication index puts new releases first. Install history oldest
|
||||
// first so the runtime check below exercises the newest published build.
|
||||
for version in entry.package.versions.into_iter().rev() {
|
||||
check(&mut rows, format!("远程安装 {}@{}", entry.package.id, version.version), async {
|
||||
let package = packages.install_from_source(source_id, &entry.package.id, &version.version, true).await?;
|
||||
ensure!(package.source_id.as_deref() == Some(source_id), "Installation wasn't bound to the remote source");
|
||||
|
||||
@@ -19,6 +19,20 @@ run(
|
||||
},
|
||||
);
|
||||
run(process.execPath, ["--test", path.join(plugin, "ui/format.test.js")]);
|
||||
run(process.execPath, ["--test", path.join(root, "tools/harmony-ui.test.mjs")]);
|
||||
const prefix = path.join(plugin, "dist/native-deps/install");
|
||||
const bridgeTest = path.join(plugin, "dist/mtp-bridge-test");
|
||||
run("cc", [
|
||||
"-I" + path.join(prefix, "include"),
|
||||
path.join(plugin, "native/tests/mtp_bridge_test.c"),
|
||||
path.join(plugin, "native/src/mtp_bridge.c"),
|
||||
"-L" + path.join(prefix, "lib"),
|
||||
"-lmtp",
|
||||
"-lusb-1.0",
|
||||
"-o",
|
||||
bridgeTest,
|
||||
]);
|
||||
run(bridgeTest, []);
|
||||
run(troveBin, [
|
||||
"protocol",
|
||||
"test",
|
||||
|
||||
Reference in New Issue
Block a user