/** * 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); });