first commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Testserver für das Add-on "Swyx Tab Bridge" – simuliert SwyxTray.
|
||||
* Ohne externe Abhängigkeiten (RFC 6455, nur Text-Frames).
|
||||
*
|
||||
* node test-server/server.js [port] [--log <datei>|--no-log] # Default 17655
|
||||
*
|
||||
* Verbindungen werden mit Zeitstempel protokolliert – auf der Konsole und in
|
||||
* test-server/swyx-tray.log. Das ist zugleich die Vorlage dafuer, was SwyxTray
|
||||
* mitschreiben sollte: Aufbau, Anmeldung (hello), Abbau samt Dauer.
|
||||
*
|
||||
* Im Terminal können Tab-Nachrichten an das Add-on geschickt werden:
|
||||
*
|
||||
* list [current] -> {"type":"tab","action":"list"}
|
||||
* open <url> [background] -> {"type":"tab","action":"open","url":"…"}
|
||||
* close <tabId> -> {"type":"tab","action":"close","tabId":42}
|
||||
* activate <tabId> -> {"type":"tab","action":"activate","tabId":42}
|
||||
* focus <url> [titel…] -> {"type":"tab","action":"focus","url":"…"}
|
||||
* raw {"type":"tab","action":"list"} -> beliebiges JSON (Nachrichten ohne
|
||||
* type:"tab" ignoriert das Add-on)
|
||||
* quit
|
||||
*/
|
||||
|
||||
const http = require("http");
|
||||
const crypto = require("crypto");
|
||||
const readline = require("readline");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
/** Wie lange nach dem Verbindungsaufbau auf die hello-Nachricht gewartet wird. */
|
||||
const HELLO_GRACE_MS = 3000;
|
||||
|
||||
const { port: PORT, logFile: LOG_FILE } = parseArgs(process.argv.slice(2));
|
||||
|
||||
let client = null; // aktuell verbundener Socket
|
||||
let clientInfo = null; // { since, remote, identity }
|
||||
let nextId = 1;
|
||||
|
||||
function parseArgs(argv) {
|
||||
let port = 17655;
|
||||
let logFile = path.join(__dirname, "swyx-tray.log");
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--log") logFile = argv[++i];
|
||||
else if (argv[i] === "--no-log") logFile = null;
|
||||
else if (/^\d+$/.test(argv[i])) port = Number(argv[i]);
|
||||
}
|
||||
return { port, logFile };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- HTTP + Upgrade */
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(426, { "Content-Type": "text/plain" });
|
||||
res.end("WebSocket only\n");
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket) => {
|
||||
const key = req.headers["sec-websocket-key"];
|
||||
if (req.headers.upgrade?.toLowerCase() !== "websocket" || !key) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const accept = crypto.createHash("sha1").update(key + GUID).digest("base64");
|
||||
socket.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`
|
||||
);
|
||||
socket.setNoDelay(true);
|
||||
|
||||
if (client) {
|
||||
log("Neue Verbindung – alte wird ersetzt.");
|
||||
try { client.end(); } catch (_) {}
|
||||
}
|
||||
|
||||
const remote = req.socket.remoteAddress;
|
||||
client = socket;
|
||||
clientInfo = { since: Date.now(), remote, identity: null };
|
||||
log(`Verbindung geöffnet von ${remote}.`);
|
||||
|
||||
// Ohne hello laesst sich nicht sagen, wer da verbunden ist. Das ist keine
|
||||
// Fehlersituation – nur bemerkenswert, wenn das Add-on erwartet wurde.
|
||||
const helloTimer = setTimeout(() => {
|
||||
if (client === socket && clientInfo && !clientInfo.identity) {
|
||||
log(`Client ${remote} hat sich nicht angemeldet (kein hello) – unbekannte Gegenstelle.`);
|
||||
rl.prompt();
|
||||
}
|
||||
}, HELLO_GRACE_MS);
|
||||
|
||||
attachFrameReader(socket, onMessage);
|
||||
|
||||
// Der Socket aus dem HTTP-Upgrade hat allowHalfOpen = true: schickt die
|
||||
// Gegenstelle ein FIN, feuert ausschliesslich 'end' und niemals 'close'.
|
||||
// Ohne die eigene Gegenschliessung bliebe die Verbindung hier ewig als
|
||||
// "verbunden" stehen und Nachrichten gingen ins Leere.
|
||||
let finished = false;
|
||||
const finish = (reason) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(helloTimer);
|
||||
try { socket.end(); } catch (_) {}
|
||||
|
||||
if (client !== socket) return; // bereits durch eine neue Verbindung ersetzt
|
||||
const who = clientInfo && clientInfo.identity ? clientInfo.identity : "Unbekannte Gegenstelle";
|
||||
const held = clientInfo ? duration(Date.now() - clientInfo.since) : "?";
|
||||
log(`Verbindung getrennt: ${who} (Dauer ${held})${reason ? " – " + reason : ""}.`);
|
||||
client = null;
|
||||
clientInfo = null;
|
||||
rl.prompt();
|
||||
};
|
||||
|
||||
socket.on("end", () => finish(null));
|
||||
socket.on("close", () => finish(null));
|
||||
socket.on("error", (err) => finish(err && err.message ? err.message : "Verbindungsfehler"));
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
log(`WebSocket-Server läuft auf ws://127.0.0.1:${PORT}`);
|
||||
log(LOG_FILE ? `Protokoll: ${LOG_FILE}` : "Protokoll: nur Konsole (--no-log)");
|
||||
log("Kommandos: list [current] | open <url> [background] | close <id> | activate <id> | focus <url> | raw <json> | quit");
|
||||
rl.prompt();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------ Frame-Handling */
|
||||
|
||||
function attachFrameReader(socket, onText) {
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
for (;;) {
|
||||
if (buffer.length < 2) return;
|
||||
|
||||
const fin = (buffer[0] & 0x80) !== 0;
|
||||
const opcode = buffer[0] & 0x0f;
|
||||
const masked = (buffer[1] & 0x80) !== 0;
|
||||
let len = buffer[1] & 0x7f;
|
||||
let offset = 2;
|
||||
|
||||
if (len === 126) {
|
||||
if (buffer.length < offset + 2) return;
|
||||
len = buffer.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
} else if (len === 127) {
|
||||
if (buffer.length < offset + 8) return;
|
||||
const big = buffer.readBigUInt64BE(offset);
|
||||
if (big > 64n * 1024n * 1024n) { socket.destroy(); return; }
|
||||
len = Number(big);
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
let mask = null;
|
||||
if (masked) {
|
||||
if (buffer.length < offset + 4) return;
|
||||
mask = buffer.subarray(offset, offset + 4);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
if (buffer.length < offset + len) return;
|
||||
|
||||
const payload = Buffer.from(buffer.subarray(offset, offset + len));
|
||||
buffer = buffer.subarray(offset + len);
|
||||
|
||||
if (mask) {
|
||||
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
||||
}
|
||||
|
||||
switch (opcode) {
|
||||
case 0x1: // Text
|
||||
if (!fin) { log("Fragmentierte Frames werden nicht unterstützt."); break; }
|
||||
onText(payload.toString("utf8"));
|
||||
break;
|
||||
case 0x8: // Close
|
||||
socket.end(encodeFrame(Buffer.alloc(0), 0x8));
|
||||
return;
|
||||
case 0x9: // Ping
|
||||
socket.write(encodeFrame(payload, 0xa));
|
||||
break;
|
||||
case 0xa: // Pong
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function encodeFrame(payload, opcode = 0x1) {
|
||||
const len = payload.length;
|
||||
let header;
|
||||
if (len < 126) {
|
||||
header = Buffer.alloc(2);
|
||||
header[1] = len;
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4);
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
header[0] = 0x80 | opcode; // FIN + Opcode, Serverframes bleiben unmaskiert
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function sendJson(obj) {
|
||||
if (!client) {
|
||||
log("Kein Client verbunden.");
|
||||
return null;
|
||||
}
|
||||
client.write(encodeFrame(Buffer.from(JSON.stringify(obj), "utf8"), 0x1));
|
||||
return obj.id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- Eingehende Nachrichten */
|
||||
|
||||
function onMessage(text) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(text);
|
||||
} catch (_) {
|
||||
log("Ungültiges JSON empfangen: " + text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Antwort des Add-ons auf eine Tab-Nachricht
|
||||
if (msg.cmd === "tabresult") {
|
||||
printResult(msg);
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Anmeldung des Add-ons. Erst hierdurch ist die Gegenstelle identifiziert.
|
||||
if (msg.cmd === "hello") {
|
||||
const identity = `${msg.client || "unbekannt"} v${msg.version || "?"}`;
|
||||
if (clientInfo) clientInfo.identity = identity;
|
||||
|
||||
const details = [];
|
||||
if (Array.isArray(msg.actions)) details.push(`Aktionen: ${msg.actions.join(", ")}`);
|
||||
details.push(msg.token ? "mit Token" : "ohne Token");
|
||||
log(`Angemeldet: ${identity} (${details.join("; ")})`);
|
||||
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
log("Unbekannte Nachricht: " + text);
|
||||
rl.prompt();
|
||||
}
|
||||
|
||||
function printResult(msg) {
|
||||
const { cmd, id, ok, error, tabs, ...rest } = msg;
|
||||
|
||||
if (ok === false) {
|
||||
log(`Fehler (#${id}): ${error || "(ohne Text)"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(tabs)) {
|
||||
log(`tabresult #${id}: ${tabs.length} Tab(s)`);
|
||||
for (const t of tabs) {
|
||||
console.log(` [${t.id}] ${t.active ? "*" : " "} ${t.title}\n ${t.url}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log(`tabresult #${id}: ok${Object.keys(rest).length ? " " + JSON.stringify(rest) : ""}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Konsole */
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "> " });
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const input = line.trim();
|
||||
if (!input) return rl.prompt();
|
||||
|
||||
const [cmd, ...rest] = input.split(/\s+/);
|
||||
|
||||
switch (cmd) {
|
||||
case "list":
|
||||
sendJson({ type: "tab", id: nextId++, action: "list", currentWindowOnly: rest[0] === "current" });
|
||||
break;
|
||||
|
||||
case "open":
|
||||
if (!rest[0]) { log("Verwendung: open <url> [background]"); break; }
|
||||
sendJson({ type: "tab", id: nextId++, action: "open", url: rest[0], active: rest[1] !== "background" });
|
||||
break;
|
||||
|
||||
case "close": {
|
||||
const tabId = Number(rest[0]);
|
||||
if (!Number.isInteger(tabId)) { log("Verwendung: close <tabId>"); break; }
|
||||
sendJson({ type: "tab", id: nextId++, action: "close", tabId });
|
||||
break;
|
||||
}
|
||||
|
||||
case "activate": {
|
||||
const tabId = Number(rest[0]);
|
||||
if (!Number.isInteger(tabId)) { log("Verwendung: activate <tabId>"); break; }
|
||||
sendJson({ type: "tab", id: nextId++, action: "activate", tabId });
|
||||
break;
|
||||
}
|
||||
|
||||
case "focus": {
|
||||
if (!rest[0]) { log("Verwendung: focus <url> [titel…]"); break; }
|
||||
const request = { type: "tab", id: nextId++, action: "focus", url: rest[0] };
|
||||
if (rest.length > 1) request.title = rest.slice(1).join(" ");
|
||||
sendJson(request);
|
||||
break;
|
||||
}
|
||||
|
||||
case "raw":
|
||||
try {
|
||||
const obj = JSON.parse(input.slice(4));
|
||||
if (obj.id === undefined) obj.id = nextId++;
|
||||
sendJson(obj);
|
||||
} catch (err) {
|
||||
log("Ungültiges JSON: " + err.message);
|
||||
}
|
||||
break;
|
||||
|
||||
case "quit":
|
||||
case "exit":
|
||||
rl.close();
|
||||
return;
|
||||
|
||||
default:
|
||||
log(`Unbekanntes Kommando: ${cmd}`);
|
||||
}
|
||||
rl.prompt();
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
try { client?.end(); } catch (_) {}
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/** Zeitspanne in Worten, fuer die Verbindungsdauer im Log. */
|
||||
function duration(ms) {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min ${seconds % 60} s`;
|
||||
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
const d = new Date();
|
||||
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
const line = `[${timestamp()}] ${msg}`;
|
||||
console.log(line);
|
||||
if (!LOG_FILE) return;
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, line + "\n");
|
||||
} catch (err) {
|
||||
// Ein nicht schreibbares Log darf den Dienst nicht anhalten.
|
||||
console.log(`(Log nicht schreibbar: ${err.message})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user