BWF Analyser: browser page and macOS app

Reads and edits BWF metadata for production sound. One source tree builds a
single self-contained page and a native Tauri app with a Rust audio engine and
WAV writer. Around 370 checks across seven test suites.

First commit of the existing state, so that from here every change can be
seen and undone.
This commit is contained in:
2026-08-17 22:50:39 +08:00
commit 2d0b7fe8b5
50 changed files with 42383 additions and 0 deletions
+626
View File
@@ -0,0 +1,626 @@
/**
* 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);
});