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'); });