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.
227 lines
9.2 KiB
JavaScript
227 lines
9.2 KiB
JavaScript
"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");
|
|
});
|