210 lines
9.0 KiB
JavaScript
210 lines
9.0 KiB
JavaScript
import fs from "node:fs";
|
||
import vm from "node:vm";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
// Prüft die Kommandoverarbeitung von background.js mit gestubbten browser-/WebSocket-APIs.
|
||
const SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "background.js");
|
||
|
||
let tabs = [
|
||
{ id: 42, windowId: 1, index: 0, title: "Kundenakte Muster GmbH", url: "https://crm.example.local/kunden/4711", active: false, status: "complete" },
|
||
{ id: 43, windowId: 1, index: 1, title: "Posteingang", url: "https://mail.example.local/", active: true, status: "complete" },
|
||
{ id: 44, windowId: 2, index: 0, title: "Suche", url: "https://www.example.com/?q=1", active: true, status: "complete" }
|
||
];
|
||
let nextTabId = 100;
|
||
const calls = [];
|
||
const broadcasts = []; // browser.runtime.sendMessage aus dem Hintergrundskript
|
||
const messageListeners = []; // Empfaenger fuer Popup-/Options-Anfragen
|
||
|
||
const browserStub = {
|
||
storage: {
|
||
local: { get: async (defaults) => ({ ...defaults }), set: async () => {} },
|
||
onChanged: { addListener: () => {} }
|
||
},
|
||
runtime: {
|
||
getManifest: () => ({ version: "1.0.0" }),
|
||
sendMessage: (msg) => { broadcasts.push(msg); return Promise.resolve(); },
|
||
onMessage: { addListener: (fn) => { messageListeners.push(fn); } }
|
||
},
|
||
browserAction: { setBadgeText: () => {}, setBadgeBackgroundColor: () => {} },
|
||
tabs: {
|
||
query: async (q) => tabs.filter((t) => {
|
||
if (q.currentWindow && t.windowId !== 1) return false;
|
||
if (q.windowId !== undefined && t.windowId !== q.windowId) return false;
|
||
if (q.active && !t.active) return false;
|
||
return true;
|
||
}),
|
||
update: async (id, props) => {
|
||
const tab = tabs.find((t) => t.id === id);
|
||
if (!tab) throw new Error(`Invalid tab ID: ${id}`);
|
||
if (props.active) tabs.forEach((t) => { if (t.windowId === tab.windowId) t.active = t.id === id; });
|
||
calls.push(`tabs.update(${id})`);
|
||
return tab;
|
||
},
|
||
create: async (props) => {
|
||
const tab = { id: nextTabId++, windowId: props.windowId ?? 1, index: tabs.length, title: "", url: props.url, active: props.active !== false, status: "loading" };
|
||
tabs.push(tab);
|
||
calls.push(`tabs.create(${props.url})`);
|
||
return tab;
|
||
},
|
||
remove: async (idOrIds) => {
|
||
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||
for (const id of ids) {
|
||
if (!tabs.some((t) => t.id === id)) throw new Error(`Invalid tab ID: ${id}`);
|
||
}
|
||
tabs = tabs.filter((t) => !ids.includes(t.id));
|
||
calls.push(`tabs.remove(${JSON.stringify(idOrIds)})`);
|
||
}
|
||
},
|
||
windows: {
|
||
getAll: async () => [{ id: 1 }, { id: 2 }],
|
||
update: async (id) => { calls.push(`windows.update(${id})`); return { id }; },
|
||
create: async (props) => {
|
||
const tab = { id: nextTabId++, windowId: 9, index: 0, title: "", url: props.url, active: true };
|
||
tabs.push(tab);
|
||
return { id: 9, tabs: [tab] };
|
||
}
|
||
}
|
||
};
|
||
|
||
const sent = [];
|
||
let sock = null;
|
||
class WebSocketStub {
|
||
static OPEN = 1; static CONNECTING = 0;
|
||
constructor(url) {
|
||
this.url = url; this.readyState = 1; this.handlers = {}; sock = this;
|
||
queueMicrotask(() => this.fire("open", {}));
|
||
}
|
||
addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); }
|
||
fire(type, ev) { (this.handlers[type] || []).forEach((fn) => fn(ev)); }
|
||
send(data) { sent.push(JSON.parse(data)); }
|
||
close() { this.readyState = 3; }
|
||
}
|
||
|
||
const ctx = vm.createContext({
|
||
browser: browserStub, WebSocket: WebSocketStub, console,
|
||
setTimeout, clearTimeout, queueMicrotask, URL, Promise, JSON, Math, Object, Array, Number, String, Error, Map, Set
|
||
});
|
||
vm.runInContext(fs.readFileSync(SRC, "utf8"), ctx, { filename: "background.js" });
|
||
|
||
const tick = () => new Promise((r) => setTimeout(r, 30));
|
||
await tick();
|
||
|
||
// Was beim Verbindungsaufbau gesendet wurde, bevor die Kommandotests `sent` leeren.
|
||
const handshake = sent.slice();
|
||
|
||
let failures = 0;
|
||
|
||
function check(label, ok, detail) {
|
||
if (!ok) failures++;
|
||
console.log(`${ok ? "✅" : "❌"} ${label}${detail === undefined ? "" : `\n → ${detail}`}`);
|
||
}
|
||
|
||
/** Fragt den Status so ab, wie Popup und Einstellungsseite es tun. */
|
||
async function getStatus() {
|
||
const results = messageListeners.map((fn) => fn({ cmd: "getStatus" })).filter(Boolean);
|
||
return await Promise.resolve(results[0]);
|
||
}
|
||
async function req(message, label, check) {
|
||
sent.length = 0;
|
||
sock.fire("message", { data: JSON.stringify(message) });
|
||
await tick();
|
||
const reply = sent[0] ?? null;
|
||
const ok = check(reply);
|
||
if (!ok) failures++;
|
||
console.log(`${ok ? "✅" : "❌"} ${label}\n → ${reply === null ? "(keine Antwort)" : JSON.stringify(reply)}`);
|
||
}
|
||
|
||
/* ---- Anmeldung und Statusanzeige ---- */
|
||
|
||
const hello = handshake.find((m) => m && m.cmd === "hello");
|
||
|
||
check("hello wird ohne Token gesendet", !!hello && !("token" in hello),
|
||
hello ? JSON.stringify(hello) : "(nichts gesendet)");
|
||
|
||
check("hello nennt Client, Version und Aktionen",
|
||
!!hello && hello.client === "swyx-tab-bridge" && hello.version === "1.0.0" &&
|
||
Array.isArray(hello.actions) && hello.actions.includes("list"));
|
||
|
||
const status = await getStatus();
|
||
|
||
check("Status meldet die Verbindung", status?.state === "connected",
|
||
JSON.stringify(status));
|
||
|
||
check("Status nennt den Zeitpunkt des Verbindungsaufbaus",
|
||
typeof status?.connectedSince === "number" && status.connectedSince <= Date.now());
|
||
|
||
check("Statuswechsel wird an Popup und Einstellungen gemeldet",
|
||
broadcasts.some((m) => m?.type === "statusChanged" && m.status?.state === "connected"));
|
||
|
||
/* ---- die drei SwyxTray-Aktionen ---- */
|
||
|
||
await req({ type: "tab", id: 1, action: "list" }, "list",
|
||
(r) => r?.cmd === "tabresult" && r.id === 1 && r.ok === true &&
|
||
r.tabs.length === 3 &&
|
||
JSON.stringify(r.tabs[0]) === JSON.stringify({ id: 42, title: "Kundenakte Muster GmbH", url: "https://crm.example.local/kunden/4711", active: false }));
|
||
|
||
await req({ type: "tab", id: 2, action: "open", url: "https://crm.example.local/kunden/4711" }, "open",
|
||
(r) => r?.cmd === "tabresult" && r.id === 2 && r.ok === true && r.tabId === 100);
|
||
|
||
await req({ type: "tab", id: 3, action: "close", tabId: 42 }, "close",
|
||
(r) => r?.cmd === "tabresult" && r.id === 3 && r.ok === true && JSON.stringify(r) === '{"cmd":"tabresult","id":3,"ok":true}');
|
||
|
||
/* ---- Fehlerfälle ---- */
|
||
|
||
await req({ type: "tab", id: 4, action: "open" }, "open ohne url",
|
||
(r) => r?.ok === false && r.error === "Feld 'url' fehlt.");
|
||
|
||
await req({ type: "tab", id: 5, action: "open", url: "javascript:alert(1)" }, "open mit javascript: abgewiesen",
|
||
(r) => r?.ok === false && r.error === "Protokoll nicht erlaubt: javascript:");
|
||
|
||
await req({ type: "tab", id: 6, action: "close" }, "close ohne tabId",
|
||
(r) => r?.ok === false && r.error === "Feld 'tabId' (number) fehlt oder ist ungültig.");
|
||
|
||
await req({ type: "tab", id: 7, action: "close", tabId: 9999 }, "close mit unbekannter tabId",
|
||
(r) => r?.cmd === "tabresult" && r.id === 7 && r.ok === false && /9999/.test(r.error));
|
||
|
||
await req({ type: "tab", id: 8, action: "foo" }, "unbekannte Aktion",
|
||
(r) => r?.ok === false && r.error === "Unbekannte Aktion 'foo'.");
|
||
|
||
/* ---- Erweiterungen ---- */
|
||
|
||
await req({ type: "tab", id: 9, action: "activate", tabId: 44 }, "activate",
|
||
(r) => r?.ok === true && r.tabId === 44 && calls.includes("windows.update(2)"));
|
||
|
||
await req({ type: "tab", id: 10, action: "focus", url: "https://mail.example.local/#posteingang" }, "focus – vorhandener Tab",
|
||
(r) => r?.ok === true && r.focused === true && r.opened === false && r.tabId === 43);
|
||
|
||
await req({ type: "tab", id: 11, action: "focus", url: "https://crm.example.local/kunden/9999" }, "focus – nicht offen ⇒ neuer Tab",
|
||
(r) => r?.ok === true && r.focused === false && r.opened === true && r.tabId >= 100);
|
||
|
||
await req({ type: "tab", id: 12, action: "open", url: "https://mail.example.local/", reuse: true }, "open mit reuse",
|
||
(r) => r?.ok === true && r.tabId === 43 && r.reused === true);
|
||
|
||
await req({ type: "tab", id: 13, action: "list", currentWindowOnly: true }, "list – nur aktuelles Fenster",
|
||
(r) => r?.ok === true && r.tabs.length === tabs.filter((t) => t.windowId === 1).length);
|
||
|
||
/* ---- Nachrichten, die ignoriert werden ---- */
|
||
|
||
await req({ id: 3, cmd: "call", number: "+49 30 1234567" }, "Telefonie-Nachricht ignoriert", (r) => r === null);
|
||
await req({ type: "call", id: 4, action: "answer" }, "type != tab ignoriert", (r) => r === null);
|
||
await req({ cmd: "tabresult", id: 1, ok: true }, "eigene Antwort-Struktur ignoriert", (r) => r === null);
|
||
|
||
sent.length = 0;
|
||
sock.fire("message", { data: "{kein json" });
|
||
await tick();
|
||
const brokenOk = sent.length === 0;
|
||
if (!brokenOk) failures++;
|
||
console.log(`${brokenOk ? "✅" : "❌"} kaputtes JSON ignoriert\n → ${sent.length ? JSON.stringify(sent[0]) : "(keine Antwort)"}`);
|
||
|
||
/* ---- id-Typen ---- */
|
||
|
||
await req({ type: "tab", id: "abc-1", action: "list" }, "id als String wird gespiegelt",
|
||
(r) => r?.id === "abc-1" && r.ok === true);
|
||
|
||
await req({ type: "tab", action: "list" }, "Anfrage ohne id",
|
||
(r) => r?.ok === true && r.id === null);
|
||
|
||
console.log(`\n${failures === 0 ? "Alle Tests bestanden." : failures + " Test(s) fehlgeschlagen."}`);
|
||
process.exit(failures ? 1 : 0);
|