diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52499d2b..33159621 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,11 @@ jobs: env: EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Use Node.js 24 for executable accessibility tests + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false - name: Use preinstalled Temurin JDK 21 # Uses the runner image's bundled JDK instead of actions/setup-java to # keep every workflow dependency hash-pinned (Scorecard Pinned-Dependencies). @@ -89,6 +94,11 @@ jobs: env: EXPECTED_SHA: ${{ github.sha }} run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Use Node.js 24 for executable accessibility tests + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false - name: Use preinstalled Temurin JDK 21 run: | echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f8da2b..9c309133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. +- 반복되는 `Details`, `Status JSON`, `Open viewer` 동작에 문서별 accessible name을 부여하고, 비동기 처리 중에는 동작별 pending label을 보조기술에 제공합니다. - **관리자용 단건 작업 삭제 및 재시도 API 추가** - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. @@ -15,6 +16,8 @@ ### Changed - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. +- 비동기 버튼 상태를 WeakMap 기반 중첩-safe 계약으로 통합했습니다. 최초 진입 시 원래 child node identity, disabled 상태, `aria-busy`, `aria-label`을 한 번만 보존하고 최종 restore에서만 정확히 복원하며, 중복 restore는 무해합니다. +- Node.js 24 DOM 회귀 테스트를 Maven `verify`와 합성 merge 검증에 포함하고 production `dom-utils.js`의 line, branch, function coverage를 각각 100%로 강제합니다. - CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다. - Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다. - Maven `verify` 이후 Surefire 보고서가 존재하고 실행 테스트 수가 1개 이상이며 skipped·failure·error 수가 모두 0인지 검증합니다. Failsafe 보고서가 생성된 경우 동일한 규칙을 적용하며, 보고서 누락·손상·음수 카운트·전체 skip·실패 결과는 exact-head CI와 merge-compatibility 모두에서 fail closed 처리합니다. @@ -24,6 +27,7 @@ ### Security +- 문서명과 같은 외부 문자열은 `textContent`와 DOM node 보존 API로만 표시하며 `innerHTML` 백업·복원을 사용하지 않습니다. 마크업처럼 보이는 파일명도 실행 가능한 HTML로 해석되지 않습니다. - Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다. - Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다. - Maven XML 테스트 보고서 검증기는 파일당 16 MiB 상한을 적용하고 한 번의 제한된 읽기로 실제 입력 크기를 검증합니다. 테스트 코드가 보고서 파일을 교체하거나 확장해도 크기 사전검사와 파싱 사이의 경쟁 조건을 이용할 수 없습니다. @@ -36,6 +40,7 @@ ### Fixed - 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. +- `Details`와 `Status JSON`의 중첩 또는 반복 비동기 호출이 원래 DOM·ARIA·disabled 상태를 조기에 복원하거나 중복 요청을 발생시키지 않도록 수정했습니다. ## [0.1.0] - 2026-06-25 diff --git a/pom.xml b/pom.xml index 362f4055..2ed62570 100644 --- a/pom.xml +++ b/pom.xml @@ -259,6 +259,33 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + run-accessibility-node-tests + test + + exec + + + node + + --test + --experimental-test-coverage + --test-coverage-include=src/main/resources/static/assets/viewer/dom-utils.js + --test-coverage-lines=100 + --test-coverage-branches=100 + --test-coverage-functions=100 + src/test/js/dom-utils.test.mjs + src/test/js/demo-integration.test.mjs + + + + + 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"); +});