org.springframework.boot
spring-boot-maven-plugin
diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js
index 51466a8b..42998be5 100644
--- a/src/main/resources/static/assets/viewer/demo.js
+++ b/src/main/resources/static/assets/viewer/demo.js
@@ -1,3 +1,5 @@
+import { createActionButton, createLink, setBusyState } from "./dom-utils.js";
+
const STORAGE_KEY = "clearfolio-demo-history-v1";
const KPI_ENDPOINT = "/api/v1/analytics/kpi-snapshot";
const KPI_EXPORTS_ENDPOINT = "/api/v1/analytics/kpi-snapshot-exports";
@@ -81,16 +83,6 @@ function updateJob(jobId, patch, { refreshKpisAfterUpdate = true } = {}) {
}
}
-function createLink(href, label) {
- const link = document.createElement("a");
- link.href = href;
- link.textContent = label;
- link.className = "table-link";
- link.target = "_blank";
- link.rel = "noopener noreferrer";
- return link;
-}
-
async function openJsonDocument(url, title) {
const popup = window.open("", "_blank");
if (!popup) {
@@ -110,15 +102,6 @@ async function openJsonDocument(url, title) {
: "Unable to load JSON evidence with the current tenant claim.";
}
-function createActionButton(label, onClick) {
- const button = document.createElement("button");
- button.type = "button";
- button.textContent = label;
- button.className = "btn btn-secondary btn-compact";
- button.addEventListener("click", onClick);
- return button;
-}
-
function jsonHeaders(extra = {}) {
return {
Accept: "application/json",
@@ -146,20 +129,17 @@ function renderHistory(history = loadHistory()) {
if (job.statusUrl) {
actionsCell.appendChild(createActionButton("Details", (e) => {
const btn = e.currentTarget;
- const initialChildren = Array.from(btn.childNodes);
- btn.disabled = true;
- btn.textContent = "Loading...";
- openJobDetail(job).finally(() => {
- btn.replaceChildren(...initialChildren);
- btn.disabled = false;
- });
- }));
- actionsCell.appendChild(createActionButton("Status JSON", () => {
- void openJsonDocument(job.statusUrl, "Clearfolio status JSON");
- }));
+ const restore = setBusyState(btn, "Loading...");
+ openJobDetail(job).finally(restore);
+ }, `View details for ${job.fileName || "Document"}`));
+ actionsCell.appendChild(createActionButton("Status JSON", (e) => {
+ const btn = e.currentTarget;
+ const restore = setBusyState(btn, "Loading status JSON...");
+ openJsonDocument(job.statusUrl, "Clearfolio status JSON").finally(restore);
+ }, `View status JSON for ${job.fileName || "Document"}`));
}
if (job.jobId) {
- actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer"));
+ actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer", `Open viewer for ${job.fileName || "Document"}`));
}
row.append(fileCell, statusCell, submittedCell, actionsCell);
@@ -264,7 +244,6 @@ async function openJobDetail(job) {
setStatus("Seeded job detail loaded.");
return;
}
-
if (!job.statusUrl) {
return;
}
@@ -297,9 +276,7 @@ async function retryActiveJob() {
}
const jobId = activeJobDetail.jobId;
- const initialChildren = Array.from(el.retryJobBtn.childNodes);
- el.retryJobBtn.disabled = true;
- el.retryJobBtn.textContent = "Retrying...";
+ const restore = setBusyState(el.retryJobBtn, "Retrying...");
setStatus("Requesting operator retry...");
try {
@@ -338,8 +315,7 @@ async function retryActiveJob() {
} catch (err) {
setError("Network error while requesting retry. Retry when the service is reachable.");
} finally {
- el.retryJobBtn.replaceChildren(...initialChildren);
- el.retryJobBtn.disabled = false;
+ restore();
}
}
@@ -412,9 +388,7 @@ async function refreshKpis() {
}
async function refreshKpiEvidence() {
- const initialChildren = Array.from(el.refreshEvidenceBtn.childNodes);
- el.refreshEvidenceBtn.disabled = true;
- el.refreshEvidenceBtn.textContent = "Refreshing...";
+ const restore = setBusyState(el.refreshEvidenceBtn, "Refreshing...");
try {
const { res, data } = await fetchJson(KPI_EXPORTS_ENDPOINT);
@@ -427,15 +401,12 @@ async function refreshKpiEvidence() {
} catch (err) {
el.kpiExportStatus.textContent = "Snapshot evidence is unavailable while the service is unreachable.";
} finally {
- el.refreshEvidenceBtn.replaceChildren(...initialChildren);
- el.refreshEvidenceBtn.disabled = false;
+ restore();
}
}
async function loadDemoData() {
- const initialChildren = Array.from(el.loadDemoDataBtn.childNodes);
- el.loadDemoDataBtn.disabled = true;
- el.loadDemoDataBtn.textContent = "Loading...";
+ const restore = setBusyState(el.loadDemoDataBtn, "Loading...");
setStatus("Loading seeded buyer-demo story...");
try {
@@ -458,8 +429,7 @@ async function loadDemoData() {
} catch (err) {
setError("Unable to load seeded demo story.");
} finally {
- el.loadDemoDataBtn.replaceChildren(...initialChildren);
- el.loadDemoDataBtn.disabled = false;
+ restore();
}
}
@@ -505,9 +475,7 @@ async function submitDocument(event) {
return;
}
- const initialChildren = Array.from(el.submitBtn.childNodes);
- el.submitBtn.disabled = true;
- el.submitBtn.textContent = "Submitting...";
+ const restore = setBusyState(el.submitBtn, "Submitting...");
setStatus("Submitting document...");
try {
@@ -550,8 +518,7 @@ async function submitDocument(event) {
addFailedHistory(file.name, "FAILED");
setError("Network error while submitting. Retry when the service is reachable.");
} finally {
- el.submitBtn.replaceChildren(...initialChildren);
- el.submitBtn.disabled = false;
+ restore();
}
}
diff --git a/src/main/resources/static/assets/viewer/dom-utils.js b/src/main/resources/static/assets/viewer/dom-utils.js
new file mode 100644
index 00000000..35ddd717
--- /dev/null
+++ b/src/main/resources/static/assets/viewer/dom-utils.js
@@ -0,0 +1,116 @@
+const busyStates = new WeakMap();
+
+/**
+ * Applies a nested-safe asynchronous busy state to a button.
+ *
+ * The first caller snapshots the button's child nodes, disabled state, and
+ * relevant ARIA attributes. Later callers only increment a depth counter.
+ * Each returned callback is idempotent, and the original state is restored
+ * only after every caller has released its busy state.
+ *
+ * @param {HTMLButtonElement} button button that starts asynchronous work
+ * @param {string} loadingText visible and accessible pending-state label
+ * @returns {() => void} idempotent callback that releases one busy-state claim
+ */
+export function setBusyState(button, loadingText) {
+ let state = busyStates.get(button);
+
+ if (state === undefined) {
+ state = {
+ depth: 0,
+ originalNodes: Array.from(button.childNodes),
+ originalDisabled: button.disabled,
+ originalAriaBusy: button.getAttribute("aria-busy"),
+ originalAriaLabel: button.getAttribute("aria-label")
+ };
+ busyStates.set(button, state);
+
+ button.disabled = true;
+ button.textContent = loadingText;
+ button.setAttribute("aria-busy", "true");
+ button.setAttribute(
+ "aria-label",
+ state.originalAriaLabel === null || state.originalAriaLabel === ""
+ ? loadingText
+ : `${loadingText} ${state.originalAriaLabel}`
+ );
+ }
+
+ state.depth += 1;
+ let restored = false;
+
+ return function restoreBusyState() {
+ if (restored) {
+ return;
+ }
+ restored = true;
+ state.depth -= 1;
+
+ if (state.depth !== 0) {
+ return;
+ }
+
+ busyStates.delete(button);
+ restoreNullableAttribute(button, "aria-busy", state.originalAriaBusy);
+ restoreNullableAttribute(button, "aria-label", state.originalAriaLabel);
+ button.replaceChildren(...state.originalNodes);
+ button.disabled = state.originalDisabled;
+ };
+}
+
+/**
+ * Creates a new-tab link with an optional contextual accessible name.
+ *
+ * @param {string} href destination URL
+ * @param {string} label visible link text
+ * @param {string | undefined} ariaLabel contextual accessible name
+ * @returns {HTMLAnchorElement} configured link element
+ */
+export function createLink(href, label, ariaLabel) {
+ const link = document.createElement("a");
+ link.href = href;
+ link.textContent = label;
+ link.className = "table-link";
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ if (ariaLabel !== undefined) {
+ link.setAttribute("aria-label", ariaLabel);
+ }
+ return link;
+}
+
+/**
+ * Creates a compact action button with an optional contextual accessible name.
+ *
+ * @param {string} label visible button text
+ * @param {(event: Event) => void} onClick click handler
+ * @param {string | undefined} ariaLabel contextual accessible name
+ * @returns {HTMLButtonElement} configured action button
+ */
+export function createActionButton(label, onClick, ariaLabel) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = label;
+ button.className = "btn btn-secondary btn-compact";
+ if (ariaLabel !== undefined) {
+ button.setAttribute("aria-label", ariaLabel);
+ }
+ button.addEventListener("click", onClick);
+ return button;
+}
+
+/**
+ * Restores an attribute exactly, distinguishing absence from an empty value.
+ *
+ * @param {HTMLElement} element element whose attribute is restored
+ * @param {string} attributeName attribute to restore
+ * @param {string | null} originalValue original value or null when absent
+ * @returns {void}
+ */
+function restoreNullableAttribute(element, attributeName, originalValue) {
+ if (originalValue === null) {
+ element.removeAttribute(attributeName);
+ } else {
+ element.setAttribute(attributeName, originalValue);
+ }
+}
diff --git a/src/test/js/demo-integration.test.mjs b/src/test/js/demo-integration.test.mjs
new file mode 100644
index 00000000..6dc00085
--- /dev/null
+++ b/src/test/js/demo-integration.test.mjs
@@ -0,0 +1,234 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+class MockTextNode {
+ constructor(text) {
+ this.type = "text";
+ this.textContent = String(text);
+ }
+}
+
+class MockElement {
+ constructor(tagName = "div") {
+ this.tagName = tagName.toUpperCase();
+ this.attributes = new Map();
+ this.childNodes = [];
+ this.listeners = new Map();
+ this.disabled = false;
+ this.hidden = false;
+ this.className = "";
+ this.type = "";
+ this.href = "";
+ this.target = "";
+ this.rel = "";
+ this.files = [];
+ }
+
+ get textContent() {
+ return this.childNodes.map(node => node.textContent).join("");
+ }
+
+ set textContent(value) {
+ const text = String(value);
+ this.childNodes = text === "" ? [] : [new MockTextNode(text)];
+ }
+
+ appendChild(node) {
+ this.childNodes.push(node);
+ return node;
+ }
+
+ append(...nodes) {
+ this.childNodes.push(...nodes);
+ }
+
+ replaceChildren(...nodes) {
+ this.childNodes = nodes;
+ }
+
+ addEventListener(type, listener) {
+ this.listeners.set(type, listener);
+ }
+
+ setAttribute(name, value) {
+ this.attributes.set(name, String(value));
+ }
+
+ getAttribute(name) {
+ return this.attributes.has(name) ? this.attributes.get(name) : null;
+ }
+
+ removeAttribute(name) {
+ this.attributes.delete(name);
+ }
+
+ focus() {}
+
+ reset() {}
+}
+
+const elementIds = [
+ "upload-form",
+ "file-input",
+ "submit-btn",
+ "demo-status",
+ "demo-error",
+ "demo-error-message",
+ "demo-error-title",
+ "load-demo-data-btn",
+ "history-body",
+ "empty-history",
+ "clear-history-btn",
+ "kpi-total",
+ "kpi-ready",
+ "kpi-success-rate",
+ "kpi-p95",
+ "kpi-export-count",
+ "kpi-export-latest",
+ "kpi-export-subject",
+ "kpi-export-jobs",
+ "kpi-export-status",
+ "refresh-evidence-btn",
+ "recovery-needs-action",
+ "recovery-retry-ready",
+ "recovery-last-action",
+ "recovery-latest-inspected",
+ "recovery-status",
+ "job-detail",
+ "job-detail-caption",
+ "job-detail-body",
+ "retry-job-btn",
+];
+
+test("the executable demo renders inert actions and blocks repeated status activation", async () => {
+ const elements = new Map(elementIds.map(id => [id, new MockElement()]));
+ const fileName = "
.pdf";
+ const history = [{
+ fileName,
+ status: "SUCCEEDED",
+ submittedAt: "2026-08-05T00:00:00Z",
+ jobId: "document-identifier",
+ statusUrl: "/api/v1/convert/jobs/document-identifier",
+ }];
+
+ globalThis.document = {
+ getElementById(id) {
+ return elements.get(id);
+ },
+ createElement(tagName) {
+ return new MockElement(tagName);
+ },
+ };
+ globalThis.window = {
+ confirm() {
+ return true;
+ },
+ open() {
+ return null;
+ },
+ setTimeout() {
+ throw new Error("completed jobs must not schedule polling");
+ },
+ };
+ globalThis.localStorage = {
+ getItem() {
+ return JSON.stringify(history);
+ },
+ setItem() {},
+ };
+ globalThis.fetch = async () => ({
+ ok: false,
+ headers: {
+ get() {
+ return "application/json";
+ },
+ },
+ async json() {
+ return null;
+ },
+ });
+
+ const moduleUrl = new URL(
+ "../../main/resources/static/assets/viewer/demo.js",
+ import.meta.url,
+ );
+ moduleUrl.searchParams.set("integration", String(Date.now()));
+ await import(moduleUrl.href);
+ await new Promise(resolve => setImmediate(resolve));
+
+ const rows = elements.get("history-body").childNodes;
+ assert.equal(rows.length, 1);
+ const [fileCell, statusCell, , actionsCell] = rows[0].childNodes;
+ assert.equal(fileCell.textContent, fileName);
+ assert.equal(fileCell.childNodes.length, 1);
+ assert.equal(fileCell.childNodes[0].type, "text");
+ assert.equal(statusCell.textContent, "SUCCEEDED");
+ assert.equal(actionsCell.childNodes.length, 3);
+ assert.equal(
+ actionsCell.childNodes[0].getAttribute("aria-label"),
+ `View details for ${fileName}`,
+ );
+ assert.equal(
+ actionsCell.childNodes[1].getAttribute("aria-label"),
+ `View status JSON for ${fileName}`,
+ );
+ assert.equal(
+ actionsCell.childNodes[2].getAttribute("aria-label"),
+ `Open viewer for ${fileName}`,
+ );
+ assert.equal(actionsCell.childNodes[2].href, "/viewer/document-identifier");
+ assert.equal(elements.get("empty-history").hidden, true);
+
+ const statusButton = actionsCell.childNodes[1];
+ const popupBody = new MockElement("body");
+ const popup = {
+ opener: {},
+ document: {
+ title: "",
+ body: popupBody,
+ createElement(tagName) {
+ return new MockElement(tagName);
+ },
+ },
+ };
+ globalThis.window.open = () => popup;
+
+ let resolveStatusFetch;
+ globalThis.fetch = () => new Promise(resolve => {
+ resolveStatusFetch = resolve;
+ });
+
+ statusButton.listeners.get("click")({ currentTarget: statusButton });
+
+ assert.equal(statusButton.disabled, true);
+ assert.equal(statusButton.textContent, "Loading status JSON...");
+ assert.equal(statusButton.getAttribute("aria-busy"), "true");
+ assert.equal(
+ statusButton.getAttribute("aria-label"),
+ `Loading status JSON... View status JSON for ${fileName}`,
+ );
+ assert.equal(popup.opener, null);
+ assert.equal(popupBody.childNodes[0].textContent, "Loading...");
+
+ resolveStatusFetch({
+ ok: true,
+ headers: {
+ get() {
+ return "application/json";
+ },
+ },
+ async json() {
+ return { status: "SUCCEEDED" };
+ },
+ });
+ await new Promise(resolve => setImmediate(resolve));
+
+ assert.equal(statusButton.disabled, false);
+ assert.equal(statusButton.textContent, "Status JSON");
+ assert.equal(statusButton.getAttribute("aria-busy"), null);
+ assert.equal(
+ statusButton.getAttribute("aria-label"),
+ `View status JSON for ${fileName}`,
+ );
+ assert.match(popupBody.childNodes[0].textContent, /"status": "SUCCEEDED"/);
+});
diff --git a/src/test/js/dom-utils.test.mjs b/src/test/js/dom-utils.test.mjs
new file mode 100644
index 00000000..2ab7b2cd
--- /dev/null
+++ b/src/test/js/dom-utils.test.mjs
@@ -0,0 +1,214 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ createActionButton,
+ createLink,
+ setBusyState
+} from "../../main/resources/static/assets/viewer/dom-utils.js";
+
+class MockTextNode {
+ constructor(text) {
+ this.type = "text";
+ this.textContent = text;
+ }
+}
+
+class MockElement {
+ constructor(tagName) {
+ this.tagName = tagName.toUpperCase();
+ this.attributes = new Map();
+ this.childNodes = [];
+ this.listeners = new Map();
+ this.disabled = false;
+ this.type = "";
+ this.className = "";
+ this.href = "";
+ this.target = "";
+ this.rel = "";
+ }
+
+ get textContent() {
+ return this.childNodes.map(node => node.textContent).join("");
+ }
+
+ set textContent(value) {
+ this.childNodes = [new MockTextNode(String(value))];
+ }
+
+ setAttribute(name, value) {
+ this.attributes.set(name, String(value));
+ }
+
+ getAttribute(name) {
+ return this.attributes.has(name) ? this.attributes.get(name) : null;
+ }
+
+ removeAttribute(name) {
+ this.attributes.delete(name);
+ }
+
+ addEventListener(name, listener) {
+ this.listeners.set(name, listener);
+ }
+
+ dispatchEvent(event) {
+ event.currentTarget = this;
+ this.listeners.get(event.type)(event);
+ }
+
+ appendChild(node) {
+ this.childNodes.push(node);
+ return node;
+ }
+
+ replaceChildren(...nodes) {
+ this.childNodes = nodes;
+ }
+}
+
+globalThis.document = {
+ createElement(tagName) {
+ return new MockElement(tagName);
+ }
+};
+
+test("setBusyState restores an enabled control and the original node identities", () => {
+ const button = new MockElement("button");
+ const icon = new MockTextNode("★");
+ const label = new MockTextNode("Submit document");
+ button.replaceChildren(icon, label);
+
+ const restore = setBusyState(button, "Submitting...");
+
+ assert.equal(button.disabled, true);
+ assert.equal(button.textContent, "Submitting...");
+ assert.equal(button.getAttribute("aria-busy"), "true");
+ assert.equal(button.getAttribute("aria-label"), "Submitting...");
+
+ restore();
+
+ assert.equal(button.disabled, false);
+ assert.equal(button.getAttribute("aria-busy"), null);
+ assert.equal(button.getAttribute("aria-label"), null);
+ assert.deepEqual(button.childNodes, [icon, label]);
+ assert.equal(button.childNodes[0], icon);
+ assert.equal(button.childNodes[1], label);
+});
+
+test("setBusyState restores initially disabled and pre-labelled controls exactly", () => {
+ const button = new MockElement("button");
+ button.disabled = true;
+ button.textContent = "Refresh evidence";
+ button.setAttribute("aria-busy", "false");
+ button.setAttribute("aria-label", "Refresh KPI evidence");
+
+ const restore = setBusyState(button, "Refreshing...");
+
+ assert.equal(button.disabled, true);
+ assert.equal(button.getAttribute("aria-busy"), "true");
+ assert.equal(button.getAttribute("aria-label"), "Refreshing... Refresh KPI evidence");
+
+ restore();
+
+ assert.equal(button.disabled, true);
+ assert.equal(button.textContent, "Refresh evidence");
+ assert.equal(button.getAttribute("aria-busy"), "false");
+ assert.equal(button.getAttribute("aria-label"), "Refresh KPI evidence");
+});
+
+test("setBusyState gives an empty original accessible name a useful loading name", () => {
+ const button = new MockElement("button");
+ button.textContent = "Retry";
+ button.setAttribute("aria-label", "");
+
+ const restore = setBusyState(button, "Retrying...");
+
+ assert.equal(button.getAttribute("aria-label"), "Retrying...");
+ restore();
+ assert.equal(button.getAttribute("aria-label"), "");
+});
+
+test("setBusyState waits for nested callers and makes every restore idempotent", () => {
+ const button = new MockElement("button");
+ button.textContent = "Details";
+ button.setAttribute("aria-label", "View details for report.pdf");
+
+ const restoreFirst = setBusyState(button, "Loading...");
+ const restoreSecond = setBusyState(button, "Loading again...");
+
+ assert.equal(button.textContent, "Loading...");
+ assert.equal(button.getAttribute("aria-label"), "Loading... View details for report.pdf");
+
+ restoreFirst();
+ restoreFirst();
+ assert.equal(button.disabled, true);
+ assert.equal(button.getAttribute("aria-busy"), "true");
+
+ restoreSecond();
+ restoreSecond();
+ assert.equal(button.disabled, false);
+ assert.equal(button.textContent, "Details");
+ assert.equal(button.getAttribute("aria-busy"), null);
+ assert.equal(button.getAttribute("aria-label"), "View details for report.pdf");
+});
+
+test("createActionButton supports contextual and omitted accessible names", () => {
+ let clicks = 0;
+ const labelled = createActionButton(
+ "Details",
+ () => {
+ clicks += 1;
+ },
+ "View details for report.pdf"
+ );
+ const unlabelled = createActionButton("Retry", () => {});
+
+ assert.equal(labelled.tagName, "BUTTON");
+ assert.equal(labelled.type, "button");
+ assert.equal(labelled.textContent, "Details");
+ assert.equal(labelled.className, "btn btn-secondary btn-compact");
+ assert.equal(labelled.getAttribute("aria-label"), "View details for report.pdf");
+ labelled.dispatchEvent({ type: "click" });
+ assert.equal(clicks, 1);
+ assert.equal(unlabelled.getAttribute("aria-label"), null);
+});
+
+test("createLink applies safe new-tab defaults and optional accessible names", () => {
+ const labelled = createLink(
+ "/viewer/document_id",
+ "Open viewer",
+ "Open viewer for report.pdf"
+ );
+ const unlabelled = createLink("/viewer/other_document", "Open viewer");
+
+ assert.equal(labelled.tagName, "A");
+ assert.equal(labelled.href, "/viewer/document_id");
+ assert.equal(labelled.textContent, "Open viewer");
+ assert.equal(labelled.className, "table-link");
+ assert.equal(labelled.target, "_blank");
+ assert.equal(labelled.rel, "noopener noreferrer");
+ assert.equal(labelled.getAttribute("aria-label"), "Open viewer for report.pdf");
+ assert.equal(unlabelled.getAttribute("aria-label"), null);
+});
+
+test("markup-like labels remain inert text in buttons and links", () => {
+ const markupLabel = "Quarterly report";
+ const button = createActionButton(
+ markupLabel,
+ () => {},
+ `Details for ${markupLabel}`
+ );
+ const link = createLink(
+ "/viewer/document_id",
+ markupLabel,
+ `Open ${markupLabel}`
+ );
+
+ assert.equal(button.textContent, markupLabel);
+ assert.equal(button.childNodes.length, 1);
+ assert.equal(button.childNodes[0].type, "text");
+ assert.equal(link.textContent, markupLabel);
+ assert.equal(link.childNodes.length, 1);
+ assert.equal(link.childNodes[0].type, "text");
+});