/**
* Loads the built index.html in jsdom exactly as a browser would (scripts
* executed in the page's own realm), feeds it synthetic BWF files through
* the real file input, and asserts the table renders the right metadata.
*
* Run: npm i jsdom && node build/test.js
*/
const fs = require("fs");
const path = require("path");
const assert = require("assert");
const { JSDOM, VirtualConsole } = require("jsdom");
const { build } = require("./make-sample.js");
const INDEX = path.join(__dirname, "..", "index.html");
const css = fs.readFileSync(INDEX, "utf8");
const createdBlobs = [];
const consoleErrors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", (e) => consoleErrors.push("jsdomError: " + e.message));
virtualConsole.on("error", (...a) => consoleErrors.push("console.error: " + a.join(" ")));
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
runScripts: "dangerously",
url: "file:///tmp/bwftest/index.html",
pretendToBeVisual: true,
virtualConsole,
beforeParse(win) {
// jsdom ships no canvas backend and no URL.createObjectURL; stub both
// before any page script runs. The waveform paths are draw-only, and
// blob URLs are only ever handed to a download anchor.
const ctxStub = new Proxy({}, {
get: (target, prop) => {
if (prop === "canvas") return null;
if (prop === "createLinearGradient") return () => ({ addColorStop() {} });
if (prop === "getImageData") return () => ({ data: new Uint8ClampedArray(4) });
if (typeof target[prop] === "undefined") return () => {};
return target[prop];
},
set: () => true,
});
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
win.URL.createObjectURL = (blob) => {
createdBlobs.push(blob);
return "blob:stub-" + createdBlobs.length;
};
win.URL.revokeObjectURL = () => {};
},
});
const { window } = dom;
function fileFrom(name, buf) {
const f = new window.File([new window.Uint8Array(buf)], name, { type: "audio/wav" });
Object.defineProperty(f, "webkitRelativePath", { value: "Day14/" + name });
return f;
}
function waitFor(fn, label, timeout = 20000) {
return new Promise((resolve, reject) => {
const started = Date.now();
(function poll() {
let value;
try {
value = fn();
} catch (e) {
return reject(e);
}
if (value) return resolve(value);
if (Date.now() - started > timeout) return reject(new Error("timed out waiting for " + label));
setTimeout(poll, 50);
})();
});
}
const results = [];
function check(name, fn) {
try {
fn();
results.push(["PASS", name]);
} catch (e) {
results.push(["FAIL", name + " — " + e.message]);
}
}
(async () => {
await new Promise((r) => window.addEventListener("load", r));
const doc = window.document;
const container = doc.querySelector("[data-bwfa-app]");
assert(container, "app container missing");
check("bwfaL10n is defined", () => assert.strictEqual(typeof window.bwfaL10n, "object"));
check("parser exposed on window.BWFA", () => assert(window.BWFA && window.BWFA.RiffParser));
check("jsPDF bundled and loaded", () => assert(window.jspdf && window.jspdf.jsPDF));
check("dropzone got its localized text", () =>
assert(doc.querySelector("[data-bwfa-dropzone-text]").textContent.trim().length > 0));
check("table head rendered on init", () =>
assert(doc.querySelector("[data-bwfa-table-head] th")));
check("detail metadata stays two pairs to a row", () => {
// The plugin's own rule (auto-fill, minmax(180px, 1fr)) lands on three
// columns at this modal width, which splits the dt/dd pairs across
// rows. The override pins it to label, value, label, value.
const probe = doc.createElement("dl");
probe.className = "bwfa-meta-grid";
container.appendChild(probe);
const style = window.getComputedStyle(probe);
assert.strictEqual((style.gridTemplateColumns.match(/minmax/g) || []).length, 4,
"expected 4 tracks, got: " + style.gridTemplateColumns);
probe.remove();
});
// Feed three files through the real input, as a folder selection would.
const files = [
fileFrom("A001_12A_T1.wav", build({ scene: "12A", take: 1 })),
fileFrom("A002_12A_T2.wav", build({ scene: "12A", take: 2, note: "plane overhead" })),
fileFrom("A003_14B_T3.wav", build({ scene: "14B", take: 3, circled: true, tcSamples: 11 * 3600 * 48000 })),
];
const notWav = fileFrom("notes.txt", Buffer.from("ignore me"));
// jsdom has no webkitdirectory support, so the app correctly removes the
// folder input and leaves the plain multi-file one — use that.
const input = doc.querySelector("[data-bwfa-files-input]");
const list = files.concat([notWav]);
list.item = (i) => list[i];
Object.defineProperty(input, "files", { value: list, configurable: true });
input.dispatchEvent(new window.Event("change", { bubbles: true }));
const status = doc.querySelector("[data-bwfa-status]");
await waitFor(() => /Done/i.test(status.textContent), "parsing to finish");
const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
check("three files parsed, non-wav skipped", () => {
assert.strictEqual(rowsOf().length, 3, "got " + rowsOf().length + " rows");
assert(/1 skipped/.test(status.textContent), status.textContent);
});
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")).map((th) =>
th.textContent.replace(/[▲▼\s]+$/, "").trim());
const cellsFor = (rowIndex) => Array.from(rowsOf()[rowIndex].querySelectorAll("td")).map((td) => td.textContent.trim());
const col = (rowIndex, header) => cellsFor(rowIndex)[headers.indexOf(header)];
check("the table reports the frame rate", () => {
assert(headers.includes("FPS"), "no FPS column: " + headers.join(" | "));
assert(/25/.test(col(0, "FPS")), "FPS cell is empty: " + col(0, "FPS"));
});
check("the report modal offers every field, all on", () => {
container.querySelector("[data-bwfa-report-open]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
const boxes = Array.from(doc.querySelectorAll("[data-bwfa-export-field]"));
assert(boxes.length >= 20, "only " + boxes.length + " fields offered");
assert(boxes.every((b) => b.checked), "some fields start unticked");
});
check("the columns cog sits in the table's first header cell", () => {
const cells = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
const cog = cells[0].querySelector("[data-bwfa-columns-open]");
assert(cog, "no cog in the first header cell: " + cells[0].innerHTML);
assert.strictEqual(cog.textContent.trim(), "", "it should be a glyph, not a word");
assert(cog.getAttribute("aria-label"), "an icon-only button needs a label");
// A smaller sibling of the eject button in the folder bar: round,
// hairline border, glyph only.
const style = window.getComputedStyle(cog);
assert.strictEqual(style.borderRadius, "50%", "it isn't round: " + style.borderRadius);
assert.strictEqual(style.width, style.height, "it isn't square, so it can't be a circle");
assert.strictEqual(style.width, "22px", "it should be smaller than the 30px eject: " + style.width);
// The border is declared with a custom property for its colour, which
// jsdom won't substitute into a computed shorthand — so read the rule.
const rules = Array.from(doc.querySelectorAll("style"))
.map((el) => el.textContent).join("\n");
const rule = rules.slice(rules.indexOf(".bwfa-columns-cog {"));
assert(/border: 1px solid/.test(rule.slice(0, rule.indexOf("}"))),
"no hairline border on the cog");
// The dropdown it replaced is gone from view.
assert.strictEqual(
window.getComputedStyle(container.querySelector("[data-bwfa-columns-dropdown]")).display,
"none", "the Columns dropdown is still in the toolbar");
});
check("the cog opens a modal listing every column", () => {
const modal = doc.querySelector("[data-bwfa-columns-modal]");
assert.strictEqual(modal.hidden, true, "it was open before anything was clicked");
doc.querySelector("[data-bwfa-columns-open]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
assert.strictEqual(modal.hidden, false, "the cog didn't open it");
assert(modal.classList.contains("open"), "the framework needs .open to show a modal");
const boxes = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] input[type=checkbox]"));
assert(boxes.length >= 15, "only " + boxes.length + " columns offered");
});
check("unticking a column takes it out of the table, then Done closes up", () => {
const modal = doc.querySelector("[data-bwfa-columns-modal]");
const labels = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] label"));
const tape = labels.filter((row) => /^Tape\/Reel$/.test(row.textContent.trim()))[0];
assert(tape, "no Tape column in the list: " + labels.map((l) => l.textContent.trim()).join(", "));
const box = tape.querySelector("input");
box.checked = false;
box.dispatchEvent(new window.Event("change", { bubbles: true }));
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
.map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim());
assert(!headers.includes("Tape/Reel"), "Tape is still a column: " + headers.join(" | "));
// And the cog survived the redraw, since the head is rebuilt each time.
assert(doc.querySelector("[data-bwfa-table-head] th [data-bwfa-columns-open]"),
"the cog went missing when the table head was redrawn");
box.checked = true;
box.dispatchEvent(new window.Event("change", { bubbles: true }));
doc.querySelector("[data-bwfa-columns-close]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
assert.strictEqual(modal.hidden, true, "Done didn't close it");
});
check("one button in the toolbar, not three", () => {
const visible = (selector) => {
const el = container.querySelector(selector);
assert(el, "no " + selector);
return window.getComputedStyle(el).display !== "none";
};
assert(visible("[data-bwfa-report-open]"), "the Sound Report button is hidden");
// And it looks like the rest of the row rather than announcing itself.
const row = Array.from(container.querySelectorAll(".bwfa-export-actions .btn"))
.filter((el) => window.getComputedStyle(el).display !== "none");
const looks = new Set(row.map((el) => el.className.replace(/\s+/g, " ").trim()));
assert.deepStrictEqual(Array.from(looks), ["btn btn-outline"],
"the toolbar mixes button styles: " + Array.from(looks).join(" / "));
// The originals stay in the DOM — the analyser wires the export
// pipeline to them and the modal drives them — but out of sight.
["[data-bwfa-export-csv]", "[data-bwfa-export-pdf]", "[data-bwfa-export-fields-toggle]"]
.forEach((selector) => assert(!visible(selector), selector + " is still on show"));
});
check("rates read in kHz everywhere, pull rates included", () => {
// 47952 is the 0.1% pull, a rate in its own right — one decimal used to
// round it to "48.0 kHz", which is a different rate.
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(48000), "48 kHz");
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(44100), "44.1 kHz");
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(47952), "47.952 kHz");
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(176400), "176.4 kHz");
// The table, and the file's own detail view.
assert(/kHz/.test(col(0, "Sample Rate")), "table cell: " + col(0, "Sample Rate"));
// The table and the detail view. The report's field list is exempt: it
// names CSV columns, and that column really is in hertz.
const onScreen = [doc.querySelector("[data-bwfa-table]"),
doc.querySelector("[data-bwfa-modal-body]")]
.map((el) => (el && el.textContent) || "").join(" ");
assert(!/\bHz\b/.test(onScreen.replace(/kHz/g, "")),
"something on screen still counts in hertz");
});
check("and the sample-rate menus read the same way", () => {
container.querySelector("[data-bwfa-bulk-edit-toggle]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
const rows = Array.from(doc.querySelectorAll(".bwfa-bulk-edit-row"));
const rateRow = rows.find((row) => /tc sample rate/i.test(row.textContent));
assert(rateRow, "no TC Sample Rate field");
const labels = Array.from(rateRow.querySelectorAll("option")).map((o) => o.textContent);
assert(labels.includes("48 kHz"), "options read: " + labels.join(" | "));
assert(labels.includes("47.952 kHz"), "the pull rate is missing: " + labels.join(" | "));
assert(!labels.some((label) => / Hz$/.test(label)), "still in hertz: " + labels.join(" | "));
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
check("the two free-text fields sit side by side, the same size", () => {
// They used to be laid out as leftovers: Note in whatever cell was
// going spare, Description alone on a row below it.
container.querySelector("[data-bwfa-bulk-edit-toggle]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
const areas = Array.from(doc.querySelectorAll("[data-bwfa-bulk-edit-fields] textarea"));
assert.strictEqual(areas.length, 2, "expected Note and Description, got " + areas.length);
const rows = areas.map((area) => area.closest(".bwfa-bulk-edit-row"));
const styles = rows.map((row) => window.getComputedStyle(row));
styles.forEach((style, i) => assert.strictEqual(style.gridColumn, "span 2",
"textarea " + i + " isn't half a row: " + style.gridColumn));
assert.strictEqual(window.getComputedStyle(rows[0]).gridColumnStart, "1",
"the first of the pair doesn't start a fresh row, so they won't line up");
const heights = areas.map((area) => window.getComputedStyle(area).height);
assert.strictEqual(new Set(heights).size, 1, "different heights: " + heights.join(" / "));
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
check("the audio escape hatch is app-only", () => {
// In a browser tab a reload loses the folder along with the audio, so
// the button that reloads is not offered there.
const action = container.querySelector("[data-bwfa-audio-reset-row]");
assert(action, "the markup should be shared, only hidden");
assert.strictEqual(window.getComputedStyle(action).display, "none",
"the reload button is on show in the browser build");
});
check("the report asks who it's for, and in what format", () => {
["company", "project", "director", "mixer", "phone", "email", "note"].forEach((key) => {
const input = container.querySelector('[data-bwfa-report-field="' + key + '"]');
assert(input, "no field for " + key);
assert.strictEqual(input.value, "", key + " starts filled in: " + input.value);
});
const format = container.querySelector("[data-bwfa-report-format]");
assert(format, "no output format control");
assert.deepStrictEqual(Array.from(format.options).map((o) => o.value), ["pdf", "csv"]);
assert.strictEqual(format.value, "pdf", "the default should be the report, not the spreadsheet");
});
container.querySelector("[data-bwfa-export-close]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
check("scene/take read from iXML", () => {
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
const takes = rowsOf().map((_, i) => col(i, "Take"));
assert.deepStrictEqual(scenes.slice().sort(), ["12A", "12A", "14B"], scenes.join(","));
assert.deepStrictEqual(takes.slice().sort(), ["1", "2", "3"], takes.join(","));
});
check("start timecode reconstructed at 25fps", () => {
const tcs = rowsOf().map((_, i) => col(i, "Start TC"));
assert(tcs.includes("10:00:00:00"), "expected 10:00:00:00 among " + tcs.join(" | "));
assert(tcs.includes("11:00:00:00"), "expected 11:00:00:00 among " + tcs.join(" | "));
});
check("format fields read from fmt chunk", () => {
assert(/48/.test(col(0, "Sample Rate")), col(0, "Sample Rate"));
assert.strictEqual(col(0, "Bit Depth"), "24-bit");
assert.strictEqual(col(0, "Channels"), "2");
});
check("circled take flagged", () => {
const circled = rowsOf().map((_, i) => col(i, "Circled"));
assert.strictEqual(circled.filter((v) => /yes/i.test(v)).length, 1, circled.join(","));
});
check("duration computed from data chunk", () => {
const d = col(0, "Duration");
assert(/00:00:01/.test(d), d);
});
// Search filter
const search = doc.querySelector("[data-bwfa-search]");
search.value = "14B";
search.dispatchEvent(new window.Event("input", { bubbles: true }));
await waitFor(() => rowsOf().length === 1, "filter to apply", 5000);
check("search filters rows", () => assert.strictEqual(rowsOf().length, 1));
search.value = "";
search.dispatchEvent(new window.Event("input", { bubbles: true }));
await waitFor(() => rowsOf().length === 3, "filter to clear", 5000);
// Sorting
const sceneHeader = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
.find((th) => /Scene/.test(th.textContent));
const clickable = sceneHeader.querySelector("button") || sceneHeader;
clickable.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
check("sorting by a column works", () => {
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
const sorted = scenes.slice().sort();
const reversed = sorted.slice().reverse();
assert(
JSON.stringify(scenes) === JSON.stringify(sorted) ||
JSON.stringify(scenes) === JSON.stringify(reversed),
scenes.join(",")
);
});
// Details modal (Track Names live here and in the CSV, not in the table).
const detailsBtn = Array.from(rowsOf()[0].querySelectorAll("button"))
.find((b) => /^(details|edit)$/i.test(b.textContent.trim()));
assert(detailsBtn, "no Edit button in the row");
detailsBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
const modal = doc.querySelector("[data-bwfa-modal]");
check("details modal opens with metadata sections", () => {
assert.strictEqual(modal.hidden, false, "modal still hidden");
const text = doc.querySelector("[data-bwfa-modal-body]").textContent;
assert(/iXML/i.test(text), "no iXML section");
assert(/Broadcast Extension|bext/i.test(text), "no bext section");
assert(/Sound Devices 833/.test(text), "originator missing");
assert(/slate/.test(text), "cue label missing");
assert(/BWF Analyser test harness/.test(text), "RIFF INFO missing");
// Track names are fields now, not labels, so they live in values
// rather than in the modal's text.
const trackNames = Array.from(modal.querySelectorAll("[data-bwfa-track-name]"))
.map((input) => input.value);
assert(trackNames.indexOf("Boom") !== -1 && trackNames.indexOf("Lav Anna") !== -1,
"iXML track names missing: " + trackNames.join(", "));
});
doc.querySelector("[data-bwfa-modal-close]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
// CSV export — the blob is captured by the stubbed createObjectURL rather
// than actually downloaded.
window.HTMLAnchorElement.prototype.click = function () {};
const isCsv = (b) => b && b.type && b.type.indexOf("csv") !== -1;
doc.querySelector("[data-bwfa-export-csv]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await waitFor(() => createdBlobs.filter(isCsv)[0], "csv blob", 5000);
const csvBlob = createdBlobs.filter(isCsv)[0];
// Blob.text() decodes as UTF-8, which swallows the BOM — check the raw
// bytes for it separately.
const csvBytes = new Uint8Array(await csvBlob.arrayBuffer());
const csvText = await csvBlob.text();
check("but the spreadsheet still counts in hertz, as it should", () => {
// kHz is how a rate is read on screen and in a printed report. A CSV of
// metadata is machine-read, and the convention there — Wave Agent, the
// recorders' own exports, the file itself — is the integer in hertz.
const head = csvText.replace(/^\ufeff/, "").split("\r\n")[0].split(",");
const row = csvText.replace(/^\ufeff/, "").split("\r\n")[1].split(",");
const at = head.indexOf("Sample Rate (Hz)");
assert(at !== -1, "the column was relabelled: " + head.join(" | "));
assert.strictEqual(row[at], "48000", "the cell says " + row[at]);
});
check("CSV export contains header and all rows", () => {
assert.deepStrictEqual(Array.from(csvBytes.slice(0, 3)), [0xef, 0xbb, 0xbf], "missing UTF-8 BOM");
assert(/\r\n/.test(csvText), "expected CRLF line endings (RFC 4180)");
assert(/Scene/.test(csvText.split("\n")[0]), "no header row");
["A001_12A_T1.wav", "A002_12A_T2.wav", "A003_14B_T3.wav"].forEach((n) =>
assert(csvText.indexOf(n) !== -1, "missing " + n));
assert(/12A/.test(csvText) && /14B/.test(csvText), "scenes missing");
assert(/Boom/.test(csvText) && /Lav Anna/.test(csvText), "track names missing from CSV");
assert(/Track Names/.test(csvText.split("\n")[0]), "Track Names column missing");
});
// The same CSV, but asked for through the report modal with the production
// details filled in.
const details = {
company: "Acme Films",
project: "The Long Weekend",
director: "R. Okonjo",
mixer: "Vincent Rozenberg",
phone: "+31 6 1234 5678",
email: "vincent@example.com",
note: "Day 4, ext. night",
};
createdBlobs.length = 0;
doc.querySelector("[data-bwfa-report-open]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
Object.keys(details).forEach((key) => {
doc.querySelector('[data-bwfa-report-field="' + key + '"]').value = details[key];
});
doc.querySelector("[data-bwfa-report-format]").value = "csv";
doc.querySelector("[data-bwfa-report-create]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await waitFor(() => createdBlobs.filter(isCsv)[0], "report csv blob", 5000);
const reportCsv = await createdBlobs.filter(isCsv)[0].text();
check("Create Report writes the format that was chosen", () => {
assert.strictEqual(createdBlobs.filter(isCsv).length, 1,
"expected one CSV, got " + createdBlobs.filter(isCsv).length);
assert.strictEqual(doc.querySelector("[data-bwfa-export-modal]").hidden, true,
"the modal stayed open over the save panel");
});
check("the CSV leads with the production details, then a blank line", () => {
const lines = reportCsv.replace(/^\ufeff/, "").split("\r\n");
assert.strictEqual(lines[0], 'Production company,Acme Films', "first line: " + lines[0]);
assert.strictEqual(lines[1], 'Project / show,The Long Weekend', "second line: " + lines[1]);
assert.strictEqual(lines[7], "", "no blank line between the details and the table: " + lines[7]);
assert(/^File Name|Scene/.test(lines[8]) || /Scene/.test(lines[8]),
"the table header should follow the blank line: " + lines[8]);
assert(reportCsv.indexOf("A001_12A_T1.wav") !== -1, "the rows went missing");
});
check("a detail with a comma in it is still one field", () => {
assert(reportCsv.indexOf('"Day 4, ext. night"') !== -1,
"the note wasn't quoted: " + reportCsv.split("\r\n").slice(0, 8).join(" / "));
});
// PDF export — jsPDF copies its API onto each instance, so the only
// reliable interception point is the constructor itself.
let pdfBytes = null;
const RealJsPDF = window.jspdf.jsPDF;
window.jspdf.jsPDF = function (options) {
const instance = new RealJsPDF(options);
instance.save = function () {
pdfBytes = this.output("arraybuffer");
return this;
};
return instance;
};
doc.querySelector("[data-bwfa-export-pdf]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await waitFor(() => pdfBytes, "pdf generation", 15000);
window.jspdf.jsPDF = RealJsPDF;
check("PDF export produces a valid PDF", () => {
const head = Buffer.from(new Uint8Array(pdfBytes).slice(0, 5)).toString("latin1");
assert.strictEqual(head, "%PDF-", "not a PDF: " + head);
assert(pdfBytes.byteLength > 1000, "suspiciously small: " + pdfBytes.byteLength);
});
// Metadata writer: round-trip an edit through the parser.
const writer = window.BWFA && window.BWFA.MetadataWriter;
check("metadata writer is available", () => assert(writer, "BWFA.MetadataWriter missing"));
// Clear hides the results panel and re-shows the empty state.
const resultsEl = doc.querySelector("[data-bwfa-results]");
const emptyEl = doc.querySelector("[data-bwfa-empty]");
doc.querySelector("[data-bwfa-clear]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await waitFor(() => resultsEl.hidden === true, "clear", 5000);
check("clear resets back to the empty state", () => {
assert.strictEqual(resultsEl.hidden, true, "results still visible");
assert.strictEqual(emptyEl.hidden, false, "empty state not shown");
assert.strictEqual(doc.querySelector("[data-bwfa-clear]").disabled, true, "clear still enabled");
assert.strictEqual(doc.querySelector("[data-bwfa-status]").textContent.trim(), "", "status not cleared");
});
// A feature wired into one build only looks exactly like a broken feature:
// the control is there, pressing it does nothing. So check the knob in the
// plain browser page as well, where there is no native engine.
check("the mixer knob works in the browser build too", () => {
const knob = doc.querySelector("[data-bwfa-mixer-open]");
assert(knob, "no mixer knob on the page");
knob.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
const modal = doc.querySelector("[data-bwfa-mixer]");
assert.strictEqual(modal.hidden, false, "the knob is dead: the modal stayed shut");
// Unhidden is not visible. This framework keeps .modal at display:none
// until it also has .open, so a test that only checks the attribute
// passes while the user sees nothing at all — which is what happened.
assert(modal.classList.contains("open"),
"the modal is unhidden but has no .open class, so it is still display:none");
const spectro = doc.querySelector("[data-bwfa-spectro]");
assert(spectro, "no spectrogram modal to compare against");
assert.strictEqual(window.getComputedStyle(modal).display,
window.getComputedStyle(spectro).display === "none"
? window.getComputedStyle(modal).display : "flex",
"the mixer isn't shown the way the other modals are");
const said = doc.querySelector("[data-bwfa-mixer-strips]").textContent;
assert(said.trim().length > 0, "the mixer opened empty and said nothing");
doc.querySelector("[data-bwfa-mixer-close]")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
assert.strictEqual(modal.hidden, true, "it wouldn't close");
assert(!modal.classList.contains("open"), "closing left the .open class behind");
});
check("the player's round buttons cannot be squashed", () => {
// The bug this exists for: a flex item with a set width still shrinks,
// and because the icon inside overflows, the button goes on looking
// perfectly normal while its hit area collapses to nothing. jsdom has
// no layout, so no amount of clicking in a test can catch it — read
// the rule instead.
const probe = doc.createElement("button");
probe.className = "bwfa-round-btn";
container.appendChild(probe);
const style = window.getComputedStyle(probe);
const shrink = style.flexShrink || style.getPropertyValue("flex-shrink");
probe.remove();
assert.strictEqual(String(shrink), "0",
"a round button can shrink (flex-shrink: " + shrink + "), which kills its hit area");
assert(/\.bwfa-round-btn\s*\{[^}]*flex:\s*none/.test(css),
"no flex: none on .bwfa-round-btn in the shipped stylesheet");
});
check("the app carries a log you can read without developer tools", () => {
// The app ships without an inspector, so "what does the console say"
// has to be answerable from inside the window.
assert(window.BWFA_DIAG, "no diagnostics on the page");
window.console.error("a deliberate error, for the log");
const captured = window.BWFA_DIAG.lines().join("\n");
assert(/a deliberate error, for the log/.test(captured),
"the log didn't catch a console error");
assert(/page built/.test(window.BWFA_DIAG.lines().join("\n")) ||
/ERROR/.test(captured), "nothing useful in the log");
// And it measures the controls, which is what tells us a visible
// button has no hit area — the failure that started all this.
window.BWFA_DIAG.probe();
const measured = window.BWFA_DIAG.lines().join("\n");
assert(/PROBE.*data-bwfa-mixer-open/.test(measured),
"the probe didn't measure the mixer knob");
// jsdom reports every box as zero, so don't assert on the numbers —
// only that the knob and the modal were both found on the page.
assert(!/data-bwfa-mixer-open\] MISSING/.test(measured),
"the probe says the knob isn't on the page at all");
assert(/mixer modal in the page: yes/.test(measured),
"the probe says the mixer modal is missing");
consoleErrors.length = 0;
});
check("mute and solo are dead centre in their circles", () => {
// .chip pads 0.85rem left against 0.5rem right, which reads fine under
// a word and visibly lopsided under a single letter.
const probe = doc.createElement("button");
probe.className = "chip bwfa-mixer-mute";
container.appendChild(probe);
const style = window.getComputedStyle(probe);
const pad = [style.paddingLeft, style.paddingRight, style.paddingTop, style.paddingBottom];
probe.remove();
assert(pad.every((v) => parseFloat(v || 0) === 0),
"the letter is pushed off centre by padding: " + pad.join(" "));
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*justify-content:\s*center/.test(css),
"nothing centres the letter horizontally");
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*align-items:\s*center/.test(css),
"nothing centres the letter vertically");
});
check("the mixer borrows the app's accent, not the system's", () => {
// The faders came up macOS blue because they asked for a --accent
// variable this app has never defined, so every one of them fell
// through to the hardcoded fallback.
assert(!/accent-color:\s*var\(\s*--accent\b/.test(css),
"something still asks for --accent, which this app doesn't define");
assert(!/#0a84ff/i.test(css), "a hardcoded system blue is still in the stylesheet");
assert(/accent-color:\s*var\(\s*--color-action\s*\)/.test(css),
"the faders don't use the app's own action colour");
});
check("the page stamps when it was built", () => {
// So "is the app running the page I just built?" stops being a matter
// of opinion. The native build embeds this page at compile time.
assert(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(window.BWFA_BUILD || ""),
"no build stamp on the page: " + window.BWFA_BUILD);
assert.strictEqual(container.getAttribute("data-bwfa-build"), window.BWFA_BUILD,
"the stamp on the page and the stamp on the app disagree");
});
check("no script errors on the page", () =>
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | ")));
console.log("");
results.forEach(([s, n]) => console.log((s === "PASS" ? " ok " : " FAIL") + " " + n));
const failed = results.filter(([s]) => s === "FAIL").length;
console.log("\n" + (results.length - failed) + "/" + results.length + " checks passed");
process.exit(failed ? 1 : 0);
})().catch((e) => {
console.error("harness error:", e);
console.error(consoleErrors.join("\n"));
process.exit(1);
});