From 2165829d0eea8a8de6b0c5ef9fc2ab1b69a28367 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 2 Nov 2026 11:24:55 -0500 Subject: [PATCH] fix(ux): confirm modal's Cancel/Confirm were dead on the promise-API path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: the Purge Archived dialog ('Permanently delete all archived items?...') opened, and Cancel did nothing — and Confirm would have been equally dead, leaving the page stuck behind the overlay until a reload. Root cause: confirm.ts wired the modal's buttons ONLY inside the htmx:confirm interceptor. confirmDialog() — the promise API used by purgeArchivedItems, remove-account, and other TS call sites — never wired them. So whenever a page's first confirm interaction came from the promise path (no hx-confirm element clicked first), both buttons had no listeners. The 2026-10-30 confirm_probe spec only exercised the hx-confirm path, which is why it survived. Fix: wireButtons() now runs at module init (the header renders before deferred modules) and inside confirmDialog() — both idempotent via the existing data-wired guard. Pinned by tests/ui/promise_confirm_probe.spec.ts (no env gating): the purge POST is route-intercepted, so the probe asserts Cancel closes the modal and Confirm issues the guarded request without deleting any dev data. hx-confirm path re-pinned green (confirm_probe with BH_FEED). Verified manually at 390x844: open -> cancelWired=true -> Cancel closes -> Confirm fires the intercepted request. --- tests/ui/COVERAGE.md | 9 +++++ tests/ui/promise_confirm_probe.spec.ts | 53 ++++++++++++++++++++++++++ web/src/confirm.ts | 13 ++++++- 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/ui/promise_confirm_probe.spec.ts diff --git a/tests/ui/COVERAGE.md b/tests/ui/COVERAGE.md index f27e7fb6..a0e1d74a 100644 --- a/tests/ui/COVERAGE.md +++ b/tests/ui/COVERAGE.md @@ -40,6 +40,15 @@ Gate: every line below executes for BOTH personas unless marked - Row 23 chains by page navigation — assert the URL becomes the next episode's reader and progress carries over. +## 2026-11-02 — promise-API confirm probe + +`promise_confirm_probe.spec.ts` (no env gating): the confirmDialog() +promise path (purge archived, remove account, …) must wire the global +modal's buttons even when no hx-confirm element fired first — the +Purge All Archived dialog opened with dead Cancel/Confirm otherwise +(user report; confirm.ts now wires at init + on every confirmDialog). +The probe intercepts the purge POST, so nothing is deleted. + ## 2026-10-30 — round-3 regression probes (live-server, env-gated) Two specs added against a RUNNING dev server (skip unless diff --git a/tests/ui/promise_confirm_probe.spec.ts b/tests/ui/promise_confirm_probe.spec.ts new file mode 100644 index 00000000..d6b7413a --- /dev/null +++ b/tests/ui/promise_confirm_probe.spec.ts @@ -0,0 +1,53 @@ +// Regression probe (2026-11-02): the CONFIRM PROMISE API path must wire +// the global modal's buttons too. purgeArchivedItems() (admin archived +// page) opens confirmDialog() directly — no hx-confirm element fires — +// and both buttons were dead whenever no hx-confirm had been clicked +// first: the modal opened and Cancel/Confirm did nothing (user report). +// The purge request itself is INTERCEPTED here — nothing is deleted. +import { test, expect } from "@playwright/test"; + +const BASE = process.env.BH_BASE ?? "http://localhost:8765"; + +test("confirmDialog promise path wires Cancel/Confirm", async ({ page }) => { + await page.addInitScript(() => { + const t = new URLSearchParams(window.location.search).get("token"); + if (t) localStorage.setItem("token", t); + }); + // testuser login via endpoint, then seed cookie+localStorage like the + // other probes (rls_sweep pattern, kept inline to stay one file). + const res = await page.request.post(`${BASE}/api/auth/login`, { + data: { login: "testuser", password: "Test@Pass123!" }, + }); + const { access_token: token } = (await res.json()) as { access_token?: string }; + expect(token).toBeTruthy(); + await page.context().addCookies([{ name: "token", value: token!, url: BASE }]); + await page.addInitScript((t) => localStorage.setItem("token", t), token!); + + // The purge POST is intercepted: Cancel/Confirm behavior is asserted + // without ever deleting the dev data. + let purgeRequested = false; + await page.route("**/api/media-items/purge-archived", (route) => { + purgeRequested = true; + route.fulfill({ status: 200, contentType: "application/json", body: '{"purged":0}' }); + }); + + await page.goto(`${BASE}/admin/archived`); + await expect(page.locator("#confirm-modal")).toBeAttached(); + + // Fresh page: no hx-confirm has fired, so pre-fix the buttons were + // unwired (data-wired absent) and Cancel did nothing. + await page.getByRole("button", { name: "Purge All Archived" }).click(); + const modal = page.locator("#confirm-modal"); + await expect(modal).toBeVisible(); + await expect(page.locator("#confirm-modal-cancel")).toHaveAttribute("data-wired", "1"); + + await page.locator("#confirm-modal-cancel").click(); + await expect(modal).toBeHidden(); + + // Confirm path: modal closes AND the guarded request issues. + await page.getByRole("button", { name: "Purge All Archived" }).click(); + await expect(modal).toBeVisible(); + await page.locator("#confirm-modal-accept").click(); + await expect(modal).toBeHidden(); + expect(purgeRequested).toBe(true); +}); diff --git a/web/src/confirm.ts b/web/src/confirm.ts index 58dda364..9f6149e6 100644 --- a/web/src/confirm.ts +++ b/web/src/confirm.ts @@ -25,6 +25,7 @@ function hide(ok: boolean): void { * Safe to call from any module (import this instead of window.confirm). */ export function confirmDialog(message: string): Promise { + wireButtons(); const { overlay, message: messageEl } = modalElements(); if (!overlay || !messageEl) { // Header missing (bare layouts): degrade to the native dialog @@ -38,7 +39,13 @@ export function confirmDialog(message: string): Promise { }); } -// Wire the static buttons once. +// Wire the static buttons once. Called at module init AND from both +// entry paths below (idempotent via data-wired): the htmx:confirm +// interceptor and the confirmDialog promise API. Wiring only on the +// htmx path left the promise path (purge archived, remove account, ...) +// with dead Cancel/Confirm buttons whenever no hx-confirm element had +// been clicked first — the modal opened and nothing could close it +// (user report: Purge Archived, 2026-11-02). function wireButtons(): void { const { overlay } = modalElements(); const accept = document.getElementById("confirm-modal-accept"); @@ -60,6 +67,10 @@ function wireButtons(): void { } } +// The header (and its confirm modal) render before deferred modules run, +// so the elements are present at import time. +wireButtons(); + // htmx:confirm interceptor: every element with hx-confirm gets the // modal instead of the browser dialog; confirming issues the request. // (Canonical htmx pattern — see htmx docs, "confirming with a modal".)