113 lines
7.1 KiB
JavaScript
113 lines
7.1 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
test("native lifecycle recovers stale ports and publishes install/remove/update state to all menus", async () => {
|
|
const ports = [], listeners = {}, storage = {}, pending = new Map(), updates = [];
|
|
let installed = false;
|
|
let helperVersion = "1.3.2";
|
|
let componentsReady = true;
|
|
const noEvent = { addListener() {} };
|
|
const runtime = { id: "test-id", getManifest: () => ({ version: "1.4.5" }), getURL: name => `chrome-extension://test-id/${name}`,
|
|
onConnect: { addListener: listener => { listeners.connect = listener; } }, onMessage: noEvent,
|
|
connectNative() {
|
|
let receive, disconnected;
|
|
const port = { sent: [], dead: false,
|
|
onMessage: { addListener: listener => { receive = listener; } },
|
|
onDisconnect: { addListener: listener => { disconnected = listener; } },
|
|
disconnect() { this.dead = true; queueMicrotask(() => disconnected()); },
|
|
emit: message => receive(message),
|
|
postMessage(message) {
|
|
if (this.dead) throw new Error("Attempting to use a disconnected port object");
|
|
this.sent.push(message);
|
|
if (!installed) {
|
|
queueMicrotask(() => { this.dead = true; runtime.lastError = { message: "Specified native messaging host not found." }; disconnected(); delete runtime.lastError; });
|
|
return;
|
|
}
|
|
const result = message.action === "hello" ? { version: helperVersion, ready: componentsReady, gpu: "Test GPU" }
|
|
: message.action === "ping" ? { alive: true }
|
|
: message.action === "helper_uninstall" ? { removed: true }
|
|
: message.action === "helper_update" ? { jobId: "update-job" } : {};
|
|
if (message.action === "helper_uninstall") installed = false;
|
|
queueMicrotask(() => receive({ id: message.id, ok: true, result }));
|
|
},
|
|
};
|
|
ports.push(port); return port;
|
|
},
|
|
};
|
|
globalThis.chrome = { runtime,
|
|
storage: { local: { get: async () => storage, set: async values => Object.assign(storage, values) } },
|
|
notifications: { clear: async () => {}, create: async () => {}, update: async () => true, onButtonClicked: noEvent },
|
|
tabs: { sendMessage: async () => {} },
|
|
action: { onClicked: noEvent, setBadgeText() {}, setBadgeBackgroundColor() {} },
|
|
};
|
|
await import("../extension/background.js?native-lifecycle-test");
|
|
let send;
|
|
const attach = collect => listeners.connect({ name: "ytdl-ui", sender: { id: runtime.id, url: runtime.getURL("downloader.html") },
|
|
onDisconnect: noEvent, onMessage: { addListener: listener => { send = listener; } },
|
|
postMessage(message) {
|
|
if (message.id) { pending.get(message.id)?.(message); pending.delete(message.id); }
|
|
else collect.push(message);
|
|
},
|
|
});
|
|
const otherMenu = []; attach(otherMenu); attach(updates);
|
|
const call = (action, payload = {}) => new Promise(resolve => {
|
|
const id = crypto.randomUUID(); pending.set(id, resolve); send({ id, action, payload });
|
|
});
|
|
assert.equal((await call("helper_start")).ok, false);
|
|
assert.equal(updates.at(-1).helper.status, "missing");
|
|
installed = true;
|
|
assert.equal((await call("helper_status")).result.status, "running", "Polling discovers a newly installed helper");
|
|
ports.at(-1).dead = true;
|
|
assert.equal((await call("helper_start")).ok, true, "One click must recover a stale native port before its disconnect callback");
|
|
const fresh = ports.at(-1);
|
|
const speedJob = { id: "speed-dropout", status: "running", stage: "downloading", track: "video", speed: 4.2 * 1048576 };
|
|
fresh.emit({ event: "job", job: speedJob });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
fresh.emit({ event: "job", job: { ...speedJob, speed: null } });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(updates.filter(message => message.job?.id === speedJob.id).at(-1).job.speed, speedJob.speed, "Native progress normalizes a missing speed before publishing to menus and notifications");
|
|
fresh.emit({ event: "job", job: { ...speedJob, track: "audio", speed: null } });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(updates.filter(message => message.job?.id === speedJob.id).at(-1).job.speed, null, "A new audio track must not inherit the video's speed");
|
|
fresh.emit({ event: "job", job: { ...speedJob, status: "cancelled", stage: "cancelled", speed: null } });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
ports.at(-2).disconnect(); await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal((await call("helper_status")).result.status, "running", "Old disconnect must not invalidate the replacement");
|
|
assert.equal((await call("helper_remove")).ok, true);
|
|
assert.equal(updates.at(-1).helper.status, "missing");
|
|
assert.equal(otherMenu.at(-1).helper.status, "missing", "Removal updates every open menu");
|
|
assert.equal((await call("helper_status")).result.status, "missing");
|
|
installed = true;
|
|
assert.equal((await call("helper_start")).ok, true);
|
|
await call("helper_update", { title: "Обновление компонентов" });
|
|
const updatingPort = ports.at(-1);
|
|
updatingPort.emit({ event: "job", job: { id: "update-job", kind: "update", status: "complete", stage: "complete", result: { restartRequired: true } } });
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
assert.notEqual(ports.at(-1), updatingPort);
|
|
assert.equal((await call("helper_status")).result.status, "running", "Component update reconnects and republishes measured capabilities");
|
|
componentsReady = false;
|
|
ports.at(-1).emit({ event: "job", job: { id: "broken-component", kind: "dependency", status: "error", stage: "error", error: "Компонент недоступен" } });
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
assert.equal((await call("state")).result.helper.hardware.ready, false, "A failed component operation refreshes readiness");
|
|
componentsReady = true;
|
|
ports.at(-1).emit({ event: "job", job: { id: "restored-component", kind: "dependency", status: "complete", stage: "complete" } });
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
assert.equal((await call("state")).result.helper.hardware.ready, true, "Restoring a component refreshes readiness without reload");
|
|
assert.ok(fresh.sent.some(message => message.action === "helper_uninstall"));
|
|
for (const [version, update] of [["1.3.0", "helper"], ["1.3.3", "extension"]]) {
|
|
helperVersion = version;
|
|
ports.at(-1).disconnect();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal((await call("helper_start")).ok, false);
|
|
const state = (await call("state")).result.helper;
|
|
assert.equal(state.status, "incompatible");
|
|
assert.equal(state.compatibility.update, update);
|
|
assert.equal(state.hardware.version, version);
|
|
const port = ports.at(-1);
|
|
assert.equal((await call("download", { url: "https://youtu.be/jNQXAC9IVRw" })).ok, false, 'Incompatible versions cannot launch a download');
|
|
assert.ok(!port.sent.some(message => message.action === "download"));
|
|
}
|
|
helperVersion = "1.3.2";
|
|
assert.equal((await call("helper_status")).result.status, "running", 'Version repair is detected by the Check button without reloading');
|
|
});
|