T7.9 - S1 (creator): errors at the field, and the last 40 native dialogs gone

wp-creation-app.js:1144 said "Subject and WP Type are required" in an alert()
on a form ten cards deep, naming nothing, focusing nothing. The creator
carried 40 native call sites in all (43 at the wave 0 count; three had
already left with D5 and T5.8's wizard work).

Inline validation, the T5.8 wizard pattern applied to the creator:
- WP_REQUIRED is one table: field id, owning section, label. The error box,
  aria-describedby, aria-invalid and the role=alert announcement all follow
  from a row. The conditional IFF rule folded in beside them.
- Submit marks every failing field, marks the rail entry of each section
  holding one (a "!" chip - a character, not only a colour), switches to the
  section of the FIRST error, scrolls to and focuses the field, and announces
  the failure through the role=alert toast.

One modal replaced confirm() and prompt(): promise-based wpConfirmDialog()/
wpPromptDialog() with an optional input whose validation renders AT the input
(a bad answer keeps the dialog open and says why - no round-trip through a
second dialog). Escape cancels; callers read like the natives they replaced,
awaited. Pure notifications became role-differentiated toasts. The modal
validation errors for the hold log and the QA rejection render inline in
their own modals.

The A1 path: confirmEarlyRelease() keeps its name and contract - truthy means
proceed with the reason recorded - and became async; every caller awaits it
(status control, hold release, urgent override, save).

App-wide native dialog count, recorded per the done-when: the probe prints it
against the wave 0 baseline of 79 and asserts the creator contributes 0. The
probe also replaces the natives with throwing stubs for the whole run, so any
path that still reached one would fail loudly.

hold_check re-pointed, not relaxed: three flows it drove through native
stubs now drive the modal - same propositions (the release-ready offer, the
named-constraints override prompt, the hard block), new surface.

Verification (each probe run alone): NEW tests/creator_dialogs_check.py
20/20. Regressions: hold_check 50/50 (re-pointed), warning_check 17/17,
qa_gate_check 40/40, triage_check 16/16, files_check 36/36, frame_check
39/39, generalinfo_check 49/49, form_structure_check 50/51 (the standing F6
height check - see the wave exit).

Items: S1 (creator half)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:46:25 -07:00
parent 82a8f30074
commit 7f712b7e00
6 changed files with 536 additions and 87 deletions

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Are the creator's 43 native dialogs gone, and did validation move inline? — S1, T7.9.
wp-creation-app.js:1144 said "Subject and WP Type are required" in an alert(),
without naming, highlighting or scrolling to the field, on a form ten cards
deep. Now: errors AT the field (role=alert, announced), submit focuses and
scrolls to the first invalid one - switching sections if needed - and the rail
entry of every section holding an error is marked with a character, not only a
colour. confirm()/prompt() became one promise-based modal with its own inline
validation.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def strip_js(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def native_count(src):
return len(re.findall(r"(?<![\w.$])(alert|confirm|prompt)\(", strip_js(src)))
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def dialog_state(page):
return json.loads(page.eval("""JSON.stringify((() => {
const ov = document.getElementById('wp-dialog');
return {open: !!ov && ov.classList.contains('open'),
title: (document.getElementById('wp-dialog-title')||{}).textContent || '',
err: (document.getElementById('wp-dialog-err')||{}).textContent || ''};
})())"""))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. the count ──────────────────────────────────────────────────────────
print("\n1. the count")
creator = 0
total = {}
for name in sorted(os.listdir(HTML)):
if not name.endswith((".js", ".html")):
continue
n = native_count(open(os.path.join(HTML, name), encoding="utf-8").read())
if n:
total[name] = n
if name in ("wp-creation-app.js", "wp-creation-index.html"):
creator += n
grand = sum(total.values())
chk("no alert(), confirm() or prompt() remains in the creator; count is 0",
creator == 0, creator)
print(" app-wide native dialogs now: %d (wave 0 baseline: 79) — %s"
% (grand, ascii_(total)))
chk("the app-wide count is recorded, and it is far below the baseline of 79",
grand < 79, grand)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-dlg-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots", wait_creator(page))
settle(1.6)
# If ANY code path still reaches a native, these throw and fail the run.
page.eval("""window.alert=()=>{throw new Error('native alert reached')};
window.confirm=()=>{throw new Error('native confirm reached')};
window.prompt=()=>{throw new Error('native prompt reached')};""")
# ── 2. inline validation on save ─────────────────────────────────────
print("\n2. required fields validate inline")
page.eval("gotoSection('signoff-card')") # start far from the errors
settle(0.6)
page.eval("void savePackage(false)")
settle(0.8)
errs = json.loads(page.eval("""JSON.stringify({
subj: (document.getElementById('wp_subject_err')||{}).textContent || '',
type: (document.getElementById('wp_type_err')||{}).textContent || '',
subjInvalid: (document.getElementById('wp_subject')||{getAttribute:()=>''}).getAttribute('aria-invalid'),
})"""))
chk("the errors render AT the fields, naming them",
"Subject is required" in errs["subj"] and "WP type is required" in errs["type"],
ascii_(errs))
chk("...with aria-invalid set", errs["subjInvalid"] == "true")
chk("...and the error boxes are alert live regions",
page.eval("(document.getElementById('wp_subject_err')||{getAttribute:()=>''})"
".getAttribute('role')") == "alert")
chk("submit switched to the section holding the first error",
page.eval("secCurrent") == "general-card", page.eval("secCurrent"))
chk("...and focused the first invalid field",
page.eval("document.activeElement && document.activeElement.id") == "wp_subject",
page.eval("document.activeElement && document.activeElement.id"))
chk("the rail marks the section that holds errors, with a character not just a colour",
page.eval("""(() => {
const b = document.querySelector('.sec-rail-item[data-sec="general-card"]');
return b && b.classList.contains('has-error')
&& (b.querySelector('.sec-err')||{}).textContent === '!';
})()"""))
chk("the failure was announced (role=alert toast)",
page.eval("(document.getElementById('toast')||{getAttribute:()=>''}).getAttribute('role')")
== "alert")
page.eval("document.getElementById('wp_subject').value='Horn strobe conduit'")
page.eval("document.getElementById('wp_type').value='Conduit Install'")
page.eval("void savePackage(false)")
settle(0.8)
chk("with the fields filled, the save goes through",
page.eval("savedPackages.length") >= 1)
chk("...the errors clear", page.eval(
"!(document.getElementById('wp_subject_err')||{textContent:''}).textContent"))
chk("...and the rail marks clear",
page.eval("!document.querySelector('.sec-rail-item.has-error')"))
# ── 3. the modal that replaced confirm() ─────────────────────────────
print("\n3. the confirm modal")
n0 = page.eval("savedPackages.length")
page.eval("void deletePackage(0)")
settle(0.5)
d = dialog_state(page)
chk("deleting asks through the modal, a real dialog element",
d["open"] and "Delete" in d["title"], ascii_(d))
page.eval("wpDialogCancel()")
settle(0.3)
chk("cancel keeps the package", page.eval("savedPackages.length") == n0)
page.eval("void deletePackage(0)")
settle(0.4)
page.eval("wpDialogOk()")
settle(0.4)
chk("confirm deletes it", page.eval("savedPackages.length") == n0 - 1)
# ── 4. the modal that replaced prompt(), with inline validation ──────
print("\n4. the prompt modal")
page.eval("newPackage()")
settle(0.5)
page.eval("document.getElementById('wp_subject').value='Copy me'")
page.eval("document.getElementById('wp_type').value='Conduit Install'")
page.eval("void duplicateWP()")
settle(0.5)
d = dialog_state(page)
chk("duplicating asks for the count through the modal",
d["open"] and "Duplicate" in d["title"], ascii_(d))
page.eval("document.getElementById('wp-dialog-input').value='banana'")
page.eval("wpDialogOk()")
settle(0.3)
d = dialog_state(page)
chk("a bad answer is refused AT the input - the dialog stays, the error says why",
d["open"] and "whole number" in d["err"], ascii_(d))
page.eval("document.getElementById('wp-dialog-input').value='2'")
n0 = page.eval("savedPackages.length")
page.eval("wpDialogOk()")
settle(0.6)
chk("a good answer proceeds", page.eval("savedPackages.length") == n0 + 2)
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors — and no path reached a native dialog (they throw here)",
not js_errors, ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())