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