Phase 5b and Phase 6: TypeScript restructure, jsdom tests, regex filters and per-playlist buffer in the UI
continuous-integration/drone/push Build is passing

TypeScript:
- menu_ts.ts split into menu.ts, content.ts, popup.ts and xmltv.ts; the
  other files drop their _ts suffix. Bundle order fixed in ts/tsconfig.json.
- All 94 string event handlers (setAttribute("onclick", "javascript: ..."))
  replaced with addEventListener closures. changeButtonAction, which
  rewrote an onclick attribute from select values, is gone.
- Settings rows are generated from one SETTINGS_FIELDS table instead of
  ~20 copy-pasted blocks; rendered HTML is byte-identical to before.
  saveSettings now serialises password inputs, so a changed Plex token is
  actually sent (the server treats the mask as unchanged).
- createLayout rebuilds the menu list only when the set of visible items
  changes, so focus in the menu survives a refresh.
- announce()/alertUser(): alerts are mirrored into an aria-live region.
- Filter popup offers a third type, Regular Expression (regex-filter), and
  the filter table labels it. Playlist popups get a Buffer select
  (default / none / xTeVe / FFmpeg / VLC) saved as the playlist's
  "buffer" parameter; the tuner field stays editable when the
  playlist's own buffer is active.

Tests: tests/ holds 27 jsdom tests (Node's built-in runner, jsdom pinned)
that load the built bundle with a fixture server payload: menu visibility
rules, mapping table renders names as text, settings panel fields, popup
flows, sorting, bulk select, layout refresh keeps nodes, live region,
regex option, buffer select. npm test runs in the Drone webui-check step.
README-DEV documents the layout and the test workflow.
This commit is contained in:
2026-09-26 13:39:35 +10:00
parent 3532679235
commit 014a3b04d2
21 changed files with 5257 additions and 4296 deletions
+218
View File
@@ -0,0 +1,218 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const vm = require("vm");
const {loadApp, readBundle} = require("./harness");
const {makeServer, M3U_ID} = require("./fixture");
function menuKeys(document) {
return Array.from(document.querySelectorAll("#main-menu li")).map(li => li.getAttribute("data-menu"));
}
function clickMenu(document, key) {
const item = document.querySelector('#main-menu li[data-menu="' + key + '"]');
assert.ok(item, "menu item " + key + " is rendered");
item.click();
return item;
}
test("bundle evaluates without throwing", () => {
const app = loadApp();
assert.equal(typeof app.window.createLayout, "function");
assert.equal(typeof app.window.PageReady, "function");
assert.equal(app.evalIn("typeof Server"), "function");
assert.equal(app.evalIn("typeof WSClient"), "function");
assert.equal(app.evalIn("menuItems.length"), 8);
});
test("bundle evaluates against a bare window without a document", () => {
const context = vm.createContext({
window: {},
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
WebSocket: function() {},
});
context.window = context;
assert.doesNotThrow(() => vm.runInContext(readBundle(), context, {filename: "app.js"}));
});
test("createLayout hides users and logout while web authentication is off", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
const keys = menuKeys(app.document);
assert.deepEqual(keys, ["playlist", "filter", "xmltv", "mapping", "settings", "log"]);
assert.equal(app.document.getElementById("version").textContent, "2.5.0");
assert.equal(app.document.querySelectorAll("#status-cards .status-card").length, 6);
});
test("createLayout shows users and logout once web authentication is on", () => {
const app = loadApp({server: makeServer({settings: {"authentication.web": true}})});
app.window.createLayout();
const keys = menuKeys(app.document);
assert.ok(keys.includes("users"), "users item present");
assert.ok(keys.includes("logout"), "logout item present");
assert.equal(keys.length, 8);
});
test("createLayout hides mapping and xmltv unless the EPG source is XEPG", () => {
const app = loadApp({server: makeServer({clientInfo: {epgSource: "PMS"}, settings: {epgSource: "PMS"}})});
app.window.createLayout();
const keys = menuKeys(app.document);
assert.ok(!keys.includes("mapping"), "mapping hidden");
assert.ok(!keys.includes("xmltv"), "xmltv hidden");
assert.ok(keys.includes("playlist"), "playlist still present");
});
test("createLayout opens the first menu item when nothing is active", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
const active = app.document.querySelector("#main-menu li.menu-active");
assert.ok(active, "an item is marked active");
assert.equal(active.getAttribute("data-menu"), "playlist");
assert.equal(app.document.body.getAttribute("data-active-menu"), "playlist");
assert.equal(app.document.querySelectorAll("#content_table tr").length, 2, "header plus one playlist row");
});
test("mapping menu renders one row per channel with channel names as text", () => {
const server = makeServer();
const app = loadApp({server});
app.window.createLayout();
clickMenu(app.document, "mapping");
const table = app.document.getElementById("content_table");
assert.ok(table, "mapping table rendered");
const channelCount = Object.keys(server.xepg.epgMapping).length;
const rows = Array.from(table.querySelectorAll("tr")).slice(1);
assert.equal(rows.length, channelCount);
const ids = rows.map(tr => tr.id).sort();
assert.deepEqual(ids, Object.keys(server.xepg.epgMapping).sort());
const hostile = app.document.getElementById(M3U_ID + "-2");
assert.ok(hostile, "row for the hostile channel name exists");
assert.equal(hostile.querySelector("b"), null, "channel name is not parsed as markup");
const names = rows.map(tr => tr.children[3].textContent);
assert.ok(names.includes("<b>x</b>"), "channel name appears literally");
assert.ok(names.includes("News 24"));
const playlists = rows.map(tr => tr.children[4].textContent);
assert.ok(playlists.every(p => p === "Provider A"), "playlist name resolved from the file id");
const guides = rows.map(tr => tr.children[6].textContent).sort();
assert.deepEqual(guides, ["-", "Guide A", "Guide A", "Guide A", "xTeVe Dummy"]);
const inactive = app.document.getElementById(M3U_ID + "-5");
assert.equal(inactive.className, "notActiveEPG");
assert.equal(app.document.getElementById(M3U_ID + "-1").className, "activeEPG");
const edit = hostile.querySelector(".mapping-edit-button");
assert.ok(edit, "edit button rendered");
assert.equal(edit.getAttribute("aria-label"), "Edit <b>x</b>");
});
test("mapping edit button opens the popup for its channel", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "mapping");
const edit = app.document.getElementById(M3U_ID + "-3").querySelector(".mapping-edit-button");
edit.click();
const popup = app.document.getElementById("popup-custom");
assert.equal(popup.querySelector("h3").textContent, "MAPPING");
assert.equal(popup.querySelector('input[name="x-name"]').value, "Sports HD");
assert.equal(popup.querySelector('select[name="x-xmltv-file"]').value, "X-1-def.xml");
assert.equal(popup.querySelector('select[name="x-mapping"]').value, "ch3.example");
});
function expectedSettingsKeys(app, webAuth) {
const all = app.evalIn("settingsCategory.map(function(c) { return c.settingsKeys }).join(',')").split(",");
if (webAuth) {
return all;
}
return all.filter(key => !["authentication.pms", "authentication.m3u", "authentication.xml", "authentication.api"].includes(key));
}
function assertSettingsControls(app, keys) {
const panel = app.document.getElementById("content_settings");
assert.ok(panel, "settings panel rendered");
const controls = Array.from(panel.querySelectorAll("input[name], select[name]"));
assert.equal(controls.length, keys.length, "one control per settings key");
for (const key of keys) {
const matches = controls.filter(c => c.getAttribute("name") === key);
assert.equal(matches.length, 1, "exactly one control for " + key);
}
return controls;
}
test("settings panel renders one input per settings key", () => {
const server = makeServer();
const app = loadApp({server});
app.window.createLayout();
clickMenu(app.document, "settings");
const keys = expectedSettingsKeys(app, false);
const controls = assertSettingsControls(app, keys);
const byName = Object.fromEntries(controls.map(c => [c.getAttribute("name"), c]));
assert.equal(byName["tuner"].tagName, "SELECT");
assert.equal(byName["tuner"].value, "1");
assert.equal(byName["buffer"].value, "ffmpeg");
assert.equal(byName["epgSource"].value, "XEPG");
assert.equal(byName["plex.token"].type, "password");
assert.equal(byName["plex.token"].value, "********");
assert.equal(byName["update"].value, "0000,1200");
assert.equal(byName["files.update"].type, "checkbox");
assert.equal(byName["files.update"].checked, true);
assert.equal(byName["cache.images"].checked, false);
assert.equal(byName["authentication.web"].checked, false);
});
test("settings panel includes the per-service authentication keys when web auth is on", () => {
const app = loadApp({server: makeServer({settings: {"authentication.web": true, "authentication.api": true}})});
app.window.createLayout();
clickMenu(app.document, "settings");
const keys = expectedSettingsKeys(app, true);
const controls = assertSettingsControls(app, keys);
const api = controls.find(c => c.getAttribute("name") === "authentication.api");
assert.equal(api.checked, true);
});
test("saveSettings sends only the changed controls", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "settings");
const tuner = app.document.querySelector('#content_settings select[name="tuner"]');
tuner.value = "4";
tuner.dispatchEvent(new app.window.Event("change", {bubbles: true}));
const agent = app.document.querySelector('#content_settings input[name="user.agent"]');
agent.value = "Custom";
agent.dispatchEvent(new app.window.Event("change", {bubbles: true}));
app.window.saveSettings();
const socket = app.sockets[app.sockets.length - 1];
assert.ok(socket, "a websocket was opened");
socket.readyState = 1;
socket.onopen();
assert.equal(socket.sent.length, 1);
const payload = JSON.parse(socket.sent[0]);
assert.equal(payload.cmd, "saveSettings");
assert.deepEqual(payload.settings, {"tuner": 4, "user.agent": "Custom"});
});
+162
View File
@@ -0,0 +1,162 @@
"use strict";
// A SERVER payload shaped like the answer to getServerConfig. Every call
// returns a fresh deep copy so tests can mutate it freely.
const M3U_ID = "M-1-abc";
const XMLTV_ID = "X-1-def";
const XMLTV_FILE = XMLTV_ID + ".xml";
function channel(number, name, extra) {
return Object.assign({
"_file.m3u.id": M3U_ID,
"_file.m3u.name": "Provider A",
"_file.m3u.path": "/config/data/" + M3U_ID + ".m3u",
"_uuid.key": "CUID",
"_uuid.value": "cuid-" + number,
"group-title": "News",
"name": name,
"tvg-id": "",
"tvg-logo": "http://logos.example/" + number + ".png",
"tvg-name": name,
"url": "http://stream.example/" + number,
"x-active": true,
"x-category": "",
"x-channelID": String(number),
"x-description": "",
"x-group-title": "News",
"x-mapping": "ch" + number + ".example",
"x-name": name,
"x-update-channel-icon": true,
"x-update-channel-name": true,
"x-xmltv-file": XMLTV_FILE,
}, extra || {});
}
function makeServer(overrides) {
const server = {
clientInfo: {
arch: "darwin/arm64",
os: "darwin",
version: "2.5.0",
uuid: "xteve-test-uuid",
DVR: "192.168.1.10:34400",
"m3u-url": "http://192.168.1.10:34400/m3u/xteve.m3u",
"xepg-url": "http://192.168.1.10:34400/xmltv/xteve.xml",
streams: "5",
epgSource: "XEPG",
xepg: "4",
errors: "0",
warnings: "1",
},
settings: {
"api": false,
"authentication.api": false,
"authentication.m3u": false,
"authentication.pms": false,
"authentication.web": false,
"authentication.xml": false,
"backup.keep": 10,
"backup.path": "/config/backup/",
"buffer": "ffmpeg",
"buffer.size.kb": 1024,
"buffer.timeout": 500,
"cache.images": false,
"epgSource": "XEPG",
"ffmpeg.options": "-hide_banner -loglevel error -i [URL] -c copy -f mpegts pipe:1",
"ffmpeg.path": "/usr/bin/ffmpeg",
"files.update": true,
"plex.token": "********",
"plex.url": "http://plex.example:32400",
"temp.path": "/tmp/xteve/",
"tuner": 1,
"udpxy": "",
"update": ["0000", "1200"],
"use_plexAPI": false,
"user.agent": "xTeVe",
"vlc.options": "--intf dummy --no-audio --no-video --sout '#standard{access=file,mux=ts,dst=-}' [URL]",
"vlc.path": "/usr/bin/cvlc",
"xepg.missing.epg.mode": "strict",
"xepg.replace.missing.images": true,
files: {
m3u: {
[M3U_ID]: {
"compatibility": {"group.title": 100, "stream.id": 100, "streams": 5, "tvg.id": 80},
"counter.download": 3,
"counter.error": 0,
"description": "Main playlist",
"file.source": "http://provider.example/list.m3u",
"file.xteve": M3U_ID + ".m3u",
"last.update": "2026-09-01 10:00:00",
"name": "Provider A",
"provider.availability": 100,
"tuner": 2,
"type": "m3u",
},
},
hdhr: {},
xmltv: {
[XMLTV_ID]: {
"compatibility": {"xmltv.channels": 40, "xmltv.programs": 12000},
"counter.download": 3,
"counter.error": 0,
"description": "Main guide",
"file.source": "http://provider.example/guide.xml",
"file.xteve": XMLTV_FILE,
"last.update": "2026-09-01 10:05:00",
"name": "Guide A",
"provider.availability": 100,
"type": "xmltv",
},
},
},
filter: {},
},
xepg: {
epgMapping: {
[M3U_ID + "-1"]: channel(1, "News 24"),
[M3U_ID + "-2"]: channel(2, "<b>x</b>"),
[M3U_ID + "-3"]: channel(3, "Sports HD", {"x-group-title": "Sport", "group-title": "Sport", "x-category": "Sports"}),
[M3U_ID + "-4"]: channel(4, "Kids TV", {"x-xmltv-file": "xTeVe Dummy", "x-mapping": "30_Minutes"}),
[M3U_ID + "-5"]: channel(5, "Unmapped", {"x-active": false, "x-xmltv-file": "-", "x-mapping": "-"}),
},
xmltvMap: {
[XMLTV_FILE]: {
"ch1.example": {id: "ch1.example", "display-name": "News 24", icon: "http://logos.example/1.png"},
"ch2.example": {id: "ch2.example", "display-name": "Channel Two", icon: ""},
"ch3.example": {id: "ch3.example", "display-name": "Sports HD", icon: "http://logos.example/3.png"},
},
"xTeVe Dummy": {
"30_Minutes": {id: "30_Minutes", "display-name": "30 Minutes", icon: ""},
"60_Minutes": {id: "60_Minutes", "display-name": "60 Minutes", icon: ""},
},
},
},
data: {
StreamPreviewUI: {activeStreams: ["News 24", "Sports HD"], inactiveStreams: ["Unmapped"]},
playlist: {m3u: {groups: {text: ["News (2)", "Sport (1)"], value: ["News", "Sport"]}}},
},
users: {},
log: {log: {}},
status: true,
};
const copy = JSON.parse(JSON.stringify(server));
if (overrides) {
deepMerge(copy, overrides);
}
return copy;
}
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
const value = source[key];
if (value && typeof value === "object" && !Array.isArray(value) && target[key] && typeof target[key] === "object") {
deepMerge(target[key], value);
} else {
target[key] = value;
}
}
}
module.exports = {makeServer, M3U_ID, XMLTV_ID, XMLTV_FILE};
+226
View File
@@ -0,0 +1,226 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {loadApp} = require("./harness");
const {makeServer, M3U_ID, XMLTV_ID} = require("./fixture");
function clickMenu(document, key) {
const item = document.querySelector('#main-menu li[data-menu="' + key + '"]');
assert.ok(item, "menu item " + key + " is rendered");
item.click();
return item;
}
function inlineHandlerAttributes(root) {
const found = [];
for (const element of root.querySelectorAll("*")) {
for (const attr of element.attributes) {
if (/^on/i.test(attr.name)) {
found.push(element.tagName + "@" + attr.name);
}
}
}
return found;
}
function change(window, element) {
element.dispatchEvent(new window.Event("change", {bubbles: true}));
}
test("rendered pages carry no inline event handler attributes", () => {
const app = loadApp({server: makeServer({settings: {"authentication.web": true}})});
app.window.createLayout();
for (const key of ["playlist", "filter", "xmltv", "mapping", "users", "settings", "log"]) {
clickMenu(app.document, key);
assert.deepEqual(inlineHandlerAttributes(app.document.body), [], "no inline handlers after opening " + key);
}
clickMenu(app.document, "playlist");
app.document.getElementById(M3U_ID).click();
assert.deepEqual(inlineHandlerAttributes(app.document.getElementById("popup-custom")), [], "no inline handlers in the m3u popup");
});
test("menu items open their content through listeners", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "xmltv");
const rows = app.document.querySelectorAll("#content_table tr");
assert.equal(rows.length, 2);
assert.equal(rows[1].id, XMLTV_ID);
assert.equal(app.document.body.getAttribute("data-active-menu"), "xmltv");
});
test("playlist rows open the edit popup and the New button walks the type chooser", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "playlist");
app.document.getElementById(M3U_ID).click();
const popup = app.document.getElementById("popup-custom");
assert.equal(popup.querySelector("h3").textContent, "M3U");
assert.equal(popup.querySelector('input[name="name"]').value, "Provider A");
assert.equal(popup.querySelector('select[name="tuner"]').value, "2");
assert.ok(popup.querySelector('input[name="delete"]'), "existing playlist offers delete");
app.document.querySelector('#content-interaction input[name="playlist"]').click();
assert.equal(popup.querySelector("h3").textContent, "PLAYLIST TYPE");
const type = popup.querySelector("select#type");
assert.deepEqual(Array.from(type.options).map(o => o.value), ["m3u", "hdhr"]);
type.value = "hdhr";
change(app.window, type);
app.document.getElementById("next").click();
assert.equal(popup.querySelector("h3").textContent, "HDHR");
assert.equal(popup.querySelector('input[name="back"]') != null, true, "new entry offers back instead of delete");
assert.equal(popup.querySelector('input[name="delete"]'), null);
popup.querySelector('input[name="back"]').click();
assert.equal(popup.querySelector("h3").textContent, "PLAYLIST TYPE");
app.document.getElementById("next").click();
assert.equal(popup.querySelector("h3").textContent, "M3U");
popup.querySelector('input[name="cancel"]').click();
assert.equal(app.document.getElementById("popup").className, "none");
});
test("filter New button offers group-title and custom filters", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "filter");
app.document.querySelector('#content-interaction input[name="filter"]').click();
const popup = app.document.getElementById("popup-custom");
assert.equal(popup.querySelector("h3").textContent, "FILTER");
const type = popup.querySelector('select[name="type"]');
assert.equal(type.value, "group-title");
type.value = "custom-filter";
change(app.window, type);
app.document.getElementById("next").click();
assert.equal(popup.querySelector("h3").textContent, "CUSTOM");
assert.equal(popup.querySelector('input[name="type"]').value, "custom-filter");
assert.ok(popup.querySelector('input[name="filter"]'), "custom filter rule input");
});
test("mapping popup marks edited controls and applies them on Done", () => {
const server = makeServer();
const app = loadApp({server});
app.window.createLayout();
clickMenu(app.document, "mapping");
const id = M3U_ID + "-1";
app.document.getElementById(id).querySelector(".mapping-edit-button").click();
const popup = app.document.getElementById("popup-custom");
const name = popup.querySelector('input[name="x-name"]');
assert.equal(name.className, "");
name.value = "Renamed";
change(app.window, name);
assert.equal(name.className, "changed");
const group = popup.querySelector('input[name="x-group-title"]');
group.value = "Docs";
change(app.window, group);
popup.querySelector('input[name="save"]').click();
const row = app.document.getElementById(id);
assert.equal(row.children[3].textContent, "Renamed");
assert.equal(row.children[5].textContent, "Docs");
assert.equal(server.xepg.epgMapping[id]["x-name"], "Renamed");
assert.equal(server.xepg.epgMapping[id]["x-group-title"], "Docs");
assert.equal(app.document.getElementById("popup").className, "none");
});
test("changing the XMLTV file in the mapping popup rebuilds the channel list", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "mapping");
app.document.getElementById(M3U_ID + "-1").querySelector(".mapping-edit-button").click();
const popup = app.document.getElementById("popup-custom");
const file = popup.querySelector("#popup-xmltv");
file.value = "xTeVe Dummy";
change(app.window, file);
const mapping = popup.querySelector("#popup-mapping");
const values = Array.from(mapping.options).map(o => o.value);
assert.ok(values.includes("30_Minutes"), "dummy programs listed");
assert.equal(mapping.className, "changed");
assert.equal(popup.querySelector("#update-icon").className, "changed");
mapping.value = "60_Minutes";
change(app.window, mapping);
assert.equal(popup.querySelector("#active").checked, true);
});
test("mapping table header sorts by channel name on click", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "mapping");
const header = app.document.querySelector("#content_table tr");
const nameHeader = header.children[3];
assert.equal(nameHeader.className, "pointer");
nameHeader.click();
const names = Array.from(app.document.querySelectorAll("#content_table tr")).slice(1).map(tr => tr.children[3].textContent);
assert.deepEqual(names, names.slice().sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1));
});
test("bulk header checkbox selects every visible channel", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "mapping");
app.document.querySelector('#content-interaction input[value="Bulk Edit"]').click();
const head = app.document.querySelector("#content_table tr input.bulk");
head.click();
const boxes = Array.from(app.document.querySelectorAll("#content_table tr")).slice(1).map(tr => tr.querySelector("input.bulk"));
assert.equal(boxes.length, 5);
assert.ok(boxes.every(b => b.checked), "every row checkbox is ticked");
assert.ok(boxes.every(b => b.className == "bulk showBulk"));
});
test("filter New button offers a regular-expression filter", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
clickMenu(app.document, "filter");
app.document.querySelector('#content-interaction input[name="filter"]').click();
const popup = app.document.getElementById("popup-custom");
const type = popup.querySelector('select[name="type"]');
const values = Array.from(type.options).map((o) => o.value);
assert.deepEqual(values, ["group-title", "custom-filter", "regex-filter"]);
type.value = "regex-filter";
change(app.window, type);
app.document.getElementById("next").click();
assert.equal(popup.querySelector("h3").textContent, "REGULAR EXPRESSION");
assert.equal(popup.querySelector('input[name="type"]').value, "regex-filter");
assert.ok(popup.querySelector('input[name="filter"]'), "regex rule input");
assert.ok(popup.querySelector('input[name="caseSensitive"]'), "case sensitive checkbox");
});
test("playlist popup offers a per-playlist buffer choice and keeps the tuner editable", () => {
const server = makeServer();
const app = loadApp({server});
app.window.createLayout();
clickMenu(app.document, "playlist");
const row = app.document.querySelector("#content_table tbody tr, #content_table tr[id]");
assert.ok(row, "a playlist row");
row.click();
const popup = app.document.getElementById("popup-custom");
const buffer = popup.querySelector('select[name="buffer"]');
assert.ok(buffer, "buffer select");
assert.deepEqual(Array.from(buffer.options).map((o) => o.value), ["default", "-", "xteve", "ffmpeg", "vlc"]);
assert.equal(buffer.value, "default");
assert.ok(popup.querySelector('select[name="tuner"]'), "tuner select shown while a buffer is active");
});
+127
View File
@@ -0,0 +1,127 @@
"use strict";
// Loads the compiled bundle (html/js/app.js) into a jsdom window so the
// global-script UI code can be driven from Node's test runner.
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const {JSDOM, VirtualConsole} = require("jsdom");
const BUNDLE_PATH = path.join(__dirname, "..", "html", "js", "app.js");
// A trimmed copy of index.html: only the ids the bundle looks up.
const SHELL_HTML = `<!doctype html>
<html>
<head><meta charset="utf-8"><title>xTeVe</title></head>
<body class="app-shell">
<div id="loading" class="none"><div class="loader"></div></div>
<div id="popup" class="none"><div id="popup-custom"></div></div>
<div id="layout-overlay"></div>
<div id="layout">
<aside id="menu-wrapper">
<nav id="main-menu"></nav>
</aside>
<main id="shell-main">
<header id="shell-header">
<button id="menu-toggle" type="button">Menu</button>
<p id="connection-indicator" class="status-idle">Connecting...</p>
</header>
<table id="clientInfo">
<tr><td id="version"></td><td id="os"></td><td id="DVR"></td></tr>
<tr><td id="uuid"></td><td id="arch"></td><td id="m3u-url"></td></tr>
<tr><td id="streams"></td><td id="epgSource"></td><td id="xepg-url"></td></tr>
<tr><td id="xepg"></td><td id="errors"></td><td id="warnings"></td></tr>
</table>
<div id="status-cards"></div>
<div id="myStreamsBox" class="notVisible">
<table id="activeStreams"></table>
<table id="inactiveStreams"></table>
</div>
<div id="content"></div>
<p id="sr-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></p>
</main>
</div>
</body>
</html>`;
function readBundle() {
return fs.readFileSync(BUNDLE_PATH, "utf8");
}
class FakeWebSocket {
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.CONNECTING;
this.sent = [];
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
FakeWebSocket.instances.push(this);
}
send(data) {
this.sent.push(data);
}
close() {
this.readyState = FakeWebSocket.CLOSED;
}
}
FakeWebSocket.CONNECTING = 0;
FakeWebSocket.OPEN = 1;
FakeWebSocket.CLOSING = 2;
FakeWebSocket.CLOSED = 3;
FakeWebSocket.instances = [];
// loadApp evaluates the bundle in a fresh window. Options:
// server - object assigned to the global SERVER after the bundle runs
// html - alternative document markup
function loadApp(options) {
options = options || {};
// The UI logs freely; keep that out of the test output but keep jsdom's
// own errors (unimplemented APIs, uncaught handler exceptions) visible.
const jsdomErrors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", err => { jsdomErrors.push(err); });
const dom = new JSDOM(options.html || SHELL_HTML, {
url: "http://localhost:34400/",
runScripts: "dangerously",
pretendToBeVisual: true,
virtualConsole,
});
const window = dom.window;
// jsdom does not implement innerText; the UI uses it as a textContent alias.
Object.defineProperty(window.HTMLElement.prototype, "innerText", {
configurable: true,
get() { return this.textContent; },
set(value) { this.textContent = value; },
});
const alerts = [];
const confirms = [];
window.alert = function(message) { alerts.push(message); };
window.confirm = function(message) { confirms.push(message); return true; };
FakeWebSocket.instances = [];
window.WebSocket = FakeWebSocket;
const context = dom.getInternalVMContext();
vm.runInContext(readBundle(), context, {filename: "app.js"});
// evalIn runs code inside the window, where top-level class declarations
// from the bundle are visible (they are lexical bindings, not window props).
function evalIn(code) {
return vm.runInContext(code, context);
}
if (options.server) {
window.SERVER = options.server;
}
return {dom, window, document: window.document, evalIn, alerts, confirms, jsdomErrors, sockets: FakeWebSocket.instances};
}
module.exports = {loadApp, readBundle, SHELL_HTML, BUNDLE_PATH};
+116
View File
@@ -0,0 +1,116 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {loadApp, SHELL_HTML} = require("./harness");
const {makeServer} = require("./fixture");
function menuKeys(document) {
return Array.from(document.querySelectorAll("#main-menu li")).map(li => li.getAttribute("data-menu"));
}
test("createLayout keeps the menu nodes when the visible items are unchanged", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
const before = Array.from(app.document.querySelectorAll("#main-menu li"));
const focused = before[2];
focused.setAttribute("tabindex", "0");
focused.focus();
assert.equal(app.document.activeElement, focused);
app.window.SERVER = makeServer({clientInfo: {streams: "9", errors: "3"}});
app.window.createLayout();
const after = Array.from(app.document.querySelectorAll("#main-menu li"));
assert.equal(after.length, before.length);
for (let i = 0; i < before.length; i++) {
assert.equal(after[i], before[i], "menu node " + i + " is the same object");
}
assert.equal(app.document.activeElement, focused, "focus survives the refresh");
assert.equal(app.document.getElementById("streams").textContent, "9", "client info refreshed");
const errorCard = Array.from(app.document.querySelectorAll("#status-cards .status-card")).find(c => c.textContent.startsWith("Errors"));
assert.ok(errorCard.className.includes("status-card-error"), "status cards refreshed");
});
test("createLayout rebuilds the menu when the visible items change", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "xmltv", "mapping", "settings", "log"]);
const playlist = app.document.querySelector('#main-menu li[data-menu="playlist"]');
app.window.SERVER = makeServer({settings: {"authentication.web": true}});
app.window.createLayout();
assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "xmltv", "mapping", "users", "settings", "log", "logout"]);
assert.notEqual(app.document.querySelector('#main-menu li[data-menu="playlist"]'), playlist, "list was rebuilt");
const active = app.document.querySelector("#main-menu li.menu-active");
assert.ok(active, "active item restored after rebuild");
assert.equal(active.getAttribute("data-menu"), "playlist");
app.window.SERVER = makeServer({clientInfo: {epgSource: "PMS"}});
app.window.createLayout();
assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "settings", "log"]);
});
test("createLayout keeps the active menu when another item is selected", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
app.document.querySelector('#main-menu li[data-menu="mapping"]').click();
assert.equal(app.document.body.getAttribute("data-active-menu"), "mapping");
app.window.SERVER = makeServer();
app.window.createLayout();
assert.equal(app.document.body.getAttribute("data-active-menu"), "mapping");
assert.equal(app.document.querySelector("#main-menu li.menu-active").getAttribute("data-menu"), "mapping");
assert.equal(app.document.querySelectorAll("#content_table tr").length, 6, "mapping content left in place");
});
test("alertUser mirrors the message into the existing live region", () => {
const app = loadApp({server: makeServer()});
app.window.alertUser("Something went wrong");
assert.deepEqual(app.alerts, ["Something went wrong"]);
assert.equal(app.document.getElementById("sr-announcer").textContent, "Something went wrong");
assert.equal(app.document.getElementById("announcer"), null, "no second region created");
});
test("announce creates a polite live region when the page has none", () => {
const html = SHELL_HTML.replace(/<p id="sr-announcer"[^>]*><\/p>/, "");
const app = loadApp({server: makeServer(), html});
assert.equal(app.document.getElementById("sr-announcer"), null);
app.window.announce("Saved");
const region = app.document.getElementById("announcer");
assert.ok(region, "region created");
assert.equal(region.getAttribute("aria-live"), "polite");
assert.equal(region.className, "sr-only");
assert.equal(region.textContent, "Saved");
app.window.announce("Again");
assert.equal(app.document.querySelectorAll("#announcer").length, 1);
assert.equal(region.textContent, "Again");
});
test("server error responses alert and announce", () => {
const app = loadApp({server: makeServer()});
app.window.createLayout();
app.evalIn('new Server("saveSettings").request({settings: {}})');
const socket = app.sockets[app.sockets.length - 1];
socket.readyState = 1;
socket.onopen();
const sent = JSON.parse(socket.sent[0]);
socket.onmessage({data: JSON.stringify({id: sent.id, status: false, err: "Setting rejected"})});
return new Promise(resolve => setImmediate(resolve)).then(() => {
assert.deepEqual(app.alerts, ["Setting rejected"]);
assert.equal(app.document.getElementById("sr-announcer").textContent, "Setting rejected");
assert.equal(app.document.getElementById("connection-indicator").className, "status-offline");
});
});