Files
Project-SDE-WP-Suite/tests/frame_check.py
n.siegfried 0dcea8d725 T9.5 - C1+S8: the help-tip is real, the audit is written, BL-001 is dead
S8, finished where the plan said it would be: every .help-tip badge is a
<button> - upgraded by the component itself at load (help.js), with
helpTipUpgrade() for late renders, so a badge added tomorrow is born
reachable. The count the task warned about came true: 15 at wave 0, 18 at the
wave 6 exit, 20 at the start of this task - all 20 buttons now, and the fix
being in the component is what stops the number growing again. One
viewport-clamped role=tooltip bubble serves every badge: focus shows it,
Escape hides it, tap toggles it, tap-elsewhere closes it - the touch path
Field View's tablets never had. The injected styles now use theme tokens
(four raw hexes of the S5 kind, gone).

BL-001, CLOSED after three causes and nine waves: the old CSS ::after escaped
its badge to the right and was the creator's last 390px overflow. The clamped
bubble ends it - scrollWidth 390 vs clientWidth 390 - and frame_check's pin
FLIPPED, exactly as designed: it asserted the failure until the fix landed,
and now asserts the fix so a regression reopens the entry loudly.

The audit (docs/reference/accessibility-audit.md), every number probe-backed:
- div/span click handlers: 12/2 at wave 0 -> 0 (the wizard's constraint
  library entries and the dashboard chips became buttons here; the comments
  backdrop stopped pretending to be a control)
- outline:none without replacement: 0 (wp-chrome's one is the documented S12
  exception - its ring is on :focus-within, one ring not two)
- aria-live: every toast system and banner announces
- native dialogs: 79 -> 21, all on surfaces no S1 task named (admin, users,
  launcher) - documented as BL-024 with the T7.9 kit ready for them
- keyboard-only primary flow: covered leg by leg by the probes that dispatch
  real CDP key events, cited in the document

Three stale count-pins re-pointed to the numbers this task reached (stepper's
baseline-minus-10, form_structure's one-span-left, frame_check's BL-001 pin) -
each now pins the TARGET so slack cannot hide a regression.

Verification (each probe run alone): NEW tests/helptip_check.py 13/13.
Regressions: a11y_check 22/22, stepper_check 71/71, form_structure_check
50/51 (BL-022's product question), pipeline_check 44/44, frame_check 38/38.

Items: C1, S8 (BL-001 closed, BL-024 opened)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:37:10 -07:00

464 lines
22 KiB
Python

#!/usr/bin/env python3
"""Is the creator's iframe actually gone? — B7 / T7.1, plus D1.
`docs/reference/creator-frame.md` measured the boundary before it was dissolved:
21 colliding stylesheet selectors, 9 colliding script globals, 0 colliding markup
ids, 28 cross-frame call sites across 8 scripts and 1 page. This checks the other
end of that work.
1. no iframe, and no cross-frame code, anywhere in html/
2. both documents still parse and boot - the failure mode a structural edit has
3. the creator is a page: app bar, tab strip, drawer, sample controls (D1)
4. every address that used to reach the creator through the frame still reaches it
5. the SOP gate still gates, and a gated tab explains itself instead of vanishing
6. every creator feature the frame used to hide is reachable
7. the four backlog entries logged against this file, re-measured
Check 2 is here because of a real failure during T7.1: a `const` shadowing a
function parameter is a SyntaxError, so `work-package-suite-app.js` did not parse
at all and every one of its globals was undefined. Four unrelated checks in
another probe went red and none of them said "the script did not load". A probe
that asserts a page's own entry points exist is cheap and says exactly that.
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
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, _c # noqa: E402
# BL-018, for the fourth time: browser_check.seed() writes a SOP whose data is
# {"governance": ...}, and nothing in the app writes that shape. restoreSavedSOP()
# bails on it, sopComplete stays false, and every creator route below would land on
# the SOP gate instead. Imported rather than copied, so the duplication stays
# visible in one place until T9.9 fixes the fixture itself.
from sections_check import set_sop # noqa: E402
HTML = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "html")
# Which boxes are wider than the screen, worst first. BL-001's own entry blamed
# --nav-w; naming the actual elements means the next person opens the right file
# instead of re-deriving it.
WIDEST_JS = r"""(() => {
const vw = document.documentElement.clientWidth;
const sw = document.documentElement.scrollWidth;
const out = [];
document.querySelectorAll('body *').forEach(el => {
const cs = getComputedStyle(el);
// A `position: fixed` drawer parked off-screen with translateX(100%) sits far
// to the right of the viewport by design and contributes nothing to
// scrollWidth. Its CHILDREN are `position: static` and ride out there with it,
// so testing the element alone is not enough - the whole subtree has to go, or
// the list fills with the five boxes inside the drawer and BL-001 gets
// attributed to the wrong file for a second time.
for (let p = el; p; p = p.parentElement) {
if (getComputedStyle(p).position === 'fixed') return;
}
const r = el.getBoundingClientRect();
if (r.right > vw + 2 || r.width > vw + 2) {
out.push({
sel: el.tagName.toLowerCase()
+ (el.id ? '#' + el.id : '')
+ (el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''),
w: Math.round(r.width), right: Math.round(r.right),
pos: cs.position,
});
}
});
out.sort((a, b) => b.right - a.right);
return JSON.stringify({scrollWidth: sw, viewport: vw, boxes: out.slice(0, 5)});
})()"""
def settle(seconds=1.0):
time.sleep(seconds)
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def read(name):
return io.open(os.path.join(HTML, name), encoding="utf-8").read()
def strip_comments(src, kind):
"""Comments are prose about the change, and prose about removing an iframe
contains the word iframe. BL-017 recorded exactly this: a grep that counts
its own explanation moves the number it is meant to be proving. Strip them.
"""
if kind == "html":
return re.sub(r"<!--.*?-->", "", src, flags=re.S)
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return re.sub(r"(?m)^\s*//.*$", "", src)
# ── 1. the source ─────────────────────────────────────────────────────────────
def source_checks():
print("\n1. no iframe, and no cross-frame code, in html/")
pages = [f for f in os.listdir(HTML) if f.endswith(".html")]
scripts = [f for f in os.listdir(HTML) if f.endswith(".js")]
framed = []
for f in pages:
body = strip_comments(read(f), "html")
if "<iframe" in body.lower():
framed.append(f)
chk("no page declares an iframe", not framed, framed)
# The four APIs that only mean something across a document boundary.
banned = {
"contentWindow": "reaching into a child document",
"contentDocument": "reading a child document",
"window.top": "asking whether we are the top document",
"window.parent": "reaching out to a parent document",
}
hits = {}
for f in scripts + pages:
body = strip_comments(read(f), "html" if f.endswith(".html") else "js")
for api, why in banned.items():
if api in body:
hits.setdefault(api + " (" + why + ")", []).append(f)
chk("no cross-frame API is called anywhere", not hits, hits)
# The embedding flag and the class set it drove.
flags = {}
for f in scripts + pages:
body = strip_comments(read(f), "html" if f.endswith(".html") else "js")
for token in ("embedded=1", "embed-hide", "embed-first", "body.embedded", "wp-frame"):
if token in body:
flags.setdefault(token, []).append(f)
chk("the embedding flag and its classes are gone", not flags, flags)
# And the sizing machinery that existed only to make a frame fill a window.
suite = strip_comments(read("work-package-suite-app.js"), "js")
gone = [n for n in ("applyEmbedLayout", "sizeWPFrame", "viewportMinusChrome",
"renderWPTab", "pushSectionsToCreator")
if n in suite]
chk("...and so is the frame-sizing machinery", not gone, gone)
# The creator was the only page loading neither of wp-chrome's two files.
creator = read("wp-creation-index.html")
chk("the creator loads the shared app bar, like every other page",
"wp-chrome.css" in creator and "wp-chrome.js" in creator)
chk("...and the tab strip lives in the sheet both tool pages load",
".nav-tab" in read("wp-chrome.css")
and ".nav-tab {" not in read("work-package-suite-styles.css"))
# ── 2. both documents boot ────────────────────────────────────────────────────
def boot_checks(page, base, tok):
print("\n2. both documents parse and boot")
for label, path, probe in (
("the SOP wizard", "/work-package-suite.html?project=projA&tab=sop",
"typeof switchTool === 'function' && typeof goToStep === 'function'"
" && typeof openCreator === 'function'"),
("the creator", "/wp-creation-index.html?project=projA",
"typeof showDashboard === 'function' && typeof newPackage === 'function'"
" && typeof syncToolTabs === 'function'"),
):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440)
page.goto(base + path)
settle(1.6)
chk("%s's own entry points are defined" % label, page.eval(probe) is True,
"script did not parse; see js_errors below")
# `beforeunload` is not a fault. Leaving a page with unsaved SOP edits is
# supposed to warn, and headless blocks the panel because there was no user
# gesture. It IS a behaviour change T7.1 introduced - a tab switch used to
# stay inside one document and never prompt - and it is recorded as BL-020
# rather than quietly suppressed here.
errs = [e for e in page.js_errors()
if "favicon" not in e and "404" not in e and "beforeunload" not in e]
chk("...and it logged no script error", not errs, errs[:2])
# ── 3. the creator is a page ──────────────────────────────────────────────────
def page_checks(page, base, tok):
print("\n3. the creator is a page, not a panel")
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440)
page.goto(base + "/wp-creation-index.html?project=projA")
chk("the creator boots", wait_creator(page))
settle(1.4)
chk("it hosts no iframe", page.eval("document.querySelectorAll('iframe').length") == 0)
chk("the app bar mounted", page.eval("!!document.querySelector('.wp-chrome')"))
chk("...with the project switcher in it",
page.eval("!!document.querySelector('.wp-chrome .wpc-proj-name, .wp-chrome button')"))
chk("the navigation drawer mounted", page.eval("!!document.querySelector('#wp-sidenav')"))
chk("the tab strip is present", page.eval(
"document.querySelectorAll('.main-nav .nav-tab').length") == 3,
page.eval("document.querySelectorAll('.main-nav .nav-tab').length"))
chk("...marking Work Package Creation as the current one", page.eval("""(() => {
const cur = document.querySelector('.nav-tab[aria-current="page"]');
return !!cur && cur.getAttribute('data-tab') === 'wp';
})()"""), page.eval("""(() => { const c = document.querySelector('.nav-tab[aria-current="page"]');
return c ? c.getAttribute('data-tab') : '(none)'; })()"""))
chk("...and the SOP tab is a link back to the wizard, carrying the project",
page.eval("""(() => {
const a = document.querySelector('a.nav-tab');
const h = a ? (a.getAttribute('href') || '') : '';
return h.indexOf('work-package-suite.html') === 0 && h.indexOf('project=projA') > 0;
})()"""),
page.eval("(document.querySelector('a.nav-tab')||{}).getAttribute"
" ? document.querySelector('a.nav-tab').getAttribute('href') : '(none)'"))
# Switching to the board must re-mark the strip; a tab row that lies about
# where you are is worse than none.
page.eval("showDashboard()")
settle(1.2)
chk("opening the board moves the current marker", page.eval("""(() => {
const cur = document.querySelector('.nav-tab[aria-current="page"]');
return !!cur && cur.getAttribute('data-tab') === 'dashboard';
})()"""))
page.eval("showForm()")
settle(1.0)
chk("...and going back to the form moves it back", page.eval("""(() => {
const cur = document.querySelector('.nav-tab[aria-current="page"]');
return !!cur && cur.getAttribute('data-tab') === 'wp';
})()"""))
print("\n D1: the controls body.embedded used to hide")
vis = json.loads(page.eval("""(() => {
const out = {};
[...document.querySelectorAll('#wp-toolbar button')].forEach(b => {
const r = b.getBoundingClientRect();
out[(b.textContent || '').trim()] = r.width > 0 && r.height > 0;
});
return JSON.stringify(out);
})()"""))
# "Usage data" left this list at T7.10: D5 moved the report to the admin
# console. Its absence HERE is asserted, so the button cannot quietly return
# and recreate the duplicate D5 existed to remove.
# "Sample SOP" + "Load example" became the ONE "Load sample data" at T9.4
# (S7): D1's point was that the sample control is reachable, and it still is.
for label in ("Load sample data", "View SOP", "Import SOP"):
chk("%-13s is visible on the unframed page" % label, vis.get(label) is True, vis)
chk("Usage data is GONE from the creator toolbar (D5)",
"Usage data" not in vis, vis)
chk("...and every one of them has a handler that exists", page.eval("""(() => {
return [...document.querySelectorAll('#wp-toolbar button')].every(b => {
const m = (b.getAttribute('onclick') || '').match(/^([A-Za-z_$][\\w$]*)\\(/);
return !m || typeof window[m[1]] === 'function';
});
})()"""), page.eval("""(() => {
return JSON.stringify([...document.querySelectorAll('#wp-toolbar button')]
.map(b => (b.getAttribute('onclick') || ''))
.filter(o => { const m = o.match(/^([A-Za-z_$][\\w$]*)\\(/);
return m && typeof window[m[1]] !== 'function'; }));
})()"""))
# ── 4. old addresses ──────────────────────────────────────────────────────────
def address_checks(page, base, tok):
print("\n4. every address that reached the creator through the frame still reaches it")
# These are in bookmarks, in wp-sidenav's link map, and they are the shape the
# CR-011 and CR-014 emails were specified against (X1). Breaking them silently
# is the one regression this task could ship that nobody would notice for weeks.
for label, path, want in (
("?tab=wp opens the creator", "/work-package-suite.html?project=projA&tab=wp", None),
("?view=dashboard opens the board",
"/work-package-suite.html?project=projA&view=dashboard", "Dashboard"),
("?wp=<id> opens that package",
"/work-package-suite.html?project=projA&wp=wpA1", "wpA1"),
):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + path)
settle(0.8)
landed = wait_creator(page)
chk(label, landed and "wp-creation-index.html" in page.eval("location.pathname"),
page.eval("location.pathname + location.search"))
settle(1.2)
if want == "Dashboard":
chk("...on the board, not the form", page.eval("""(() => {
const dv = document.getElementById('dashboard-view');
return !!dv && getComputedStyle(dv).display !== 'none';
})()"""))
elif want == "wpA1":
chk("...with that package in the form", "horn/strobe" in (page.eval(
"(document.getElementById('wp_subject')||{}).value||''") or ""),
page.eval("(document.getElementById('wp_subject')||{}).value||''"))
print("\n ...and the forward is a replace, so Back is not a bounce")
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + "/index.html?project=projA")
settle(1.4)
page.eval("location.assign('work-package-suite.html?project=projA&tab=wp')")
settle(0.8)
wait_creator(page)
settle(1.2)
page.eval("history.back()")
settle(1.8)
where = page.eval("location.pathname")
chk("Back from a forwarded link returns to where you came from",
"index.html" in where, where)
# ── 5. the gate ───────────────────────────────────────────────────────────────
def gate_checks(page, base, tok):
print("\n5. the SOP gate still gates, and says so")
# projB has members but no SOP.
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + "/work-package-suite.html?project=projB&tab=sop")
settle(1.8)
chk("with no SOP, the creator tab is marked unavailable", page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="wp"]');
return !!a && a.getAttribute('aria-disabled') === 'true';
})()"""), page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="wp"]');
return a ? (a.getAttribute('aria-disabled') || '(not set)') : 'missing'; })()"""))
chk("...but it is still a real control, in the tab order", page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="wp"]');
if (!a) return false;
a.focus();
return document.activeElement === a;
})()"""))
page.eval("""(() => { document.querySelector('.nav-tab[data-tab="wp"]').click(); })()""")
settle(1.2)
chk("...clicking it does not navigate", "work-package-suite.html" in page.eval("location.pathname"),
page.eval("location.pathname"))
chk("...it opens the gate panel instead", page.eval("""(() => {
const g = document.getElementById('wp-gate');
return !!g && getComputedStyle(g).display !== 'none';
})()"""))
chk("...which says what to do about it", "SOP" in (page.eval(
"(document.getElementById('wp-gate')||{}).textContent||''") or ""))
# ── 6. the backlog entries logged against this file ───────────────────────────
def measurement(page, base, tok):
print("\n6. the four backlog entries logged against the creator, re-measured")
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
# BL-001: the creator laid out 485px of content in a 390px viewport, because a
# runtime <style> injected --nav-w:288px with no media query and beat the
# stylesheet's own breakpoint.
page.viewport(390, 900, mobile=True)
page.goto(base + "/wp-creation-index.html?project=projA")
wait_creator(page)
settle(1.4)
over = json.loads(page.eval("""JSON.stringify({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
navw: getComputedStyle(document.body).getPropertyValue('--nav-w').trim(),
})"""))
print(" BL-001 390px: scrollWidth %s vs clientWidth %s, --nav-w %s"
% (over["scroll"], over["client"], over["navw"] or "(unset)"))
if over["scroll"] > over["client"] + 2:
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
# CLOSED at T9.5. The pin below held this failure in view from T7.1 until the
# cause was actually removed: the help-tip's CSS ::after escaped its badge to
# the right, and rebuilding the component (S8) with a viewport-clamped bubble
# ended the overflow. The check now asserts the FIX, so a regression reopens
# BL-001 loudly instead of quietly re-widening the page.
chk("BL-001 is closed: the creator does not overflow at 390px",
over["scroll"] <= over["client"] + 2, over)
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
# a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it,
# so T7.2 inherits a measurement instead of an assumption.
page.viewport(1440)
page.goto(base + "/wp-creation-index.html?project=projA")
wait_creator(page)
settle(1.2)
# Without this the headless document is not focused, :focus-visible never
# matches, and EVERY control reports no ring - a reading that looks like a
# finding and is an artefact. It is `page.ws.call`, not `page.call`: an earlier
# draft of this probe used the latter inside a try/except and silently measured
# nothing at all, which is worse than not measuring.
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
chk("focus emulation is on, so a focus reading means something",
page.eval("document.hasFocus()") is True)
ring = page.eval("""(() => {
const el = document.querySelector('#wp_subject, .main input[type=text]');
if (!el) return 'no input found';
el.focus();
const cs = getComputedStyle(el);
return 'outline ' + cs.outlineStyle + ' ' + cs.outlineWidth
+ ' | box-shadow ' + cs.boxShadow
+ ' | focused=' + (document.activeElement === el);
})()""")
print(" BL-013 focused creator input: %s" % ring)
sheet = read("wp-creation-styles.css")
halves = len(re.findall(r"\b\d+\.5px", sheet))
radii = len(re.findall(r"border-radius:\s*(?!var\()", sheet))
print(" BL-006 half-pixel font sizes in wp-creation-styles.css: %d" % halves)
print(" BL-007 raw border-radius values in the same sheet: %d" % radii)
return {"bl001": over, "bl013": ring, "bl006": halves, "bl007": radii}
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-frame-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
set_sop(db_path, {}) # a SOP the app can actually read (BL-018)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
if server is None:
print("the test server would not start.")
return 2
print("\nThe creator's iframe - B7 / T7.1\nTarget: %s" % base)
source_checks()
browser = cdp.Browser(exe)
page = browser.page()
try:
boot_checks(page, base, tok)
page_checks(page, base, tok)
address_checks(page, base, tok)
gate_checks(page, base, tok)
measurement(page, base, tok)
finally:
page.close()
browser.close()
print("\n" + "-" * 54)
total = len(_PASS) + len(_FAIL)
print("%d/%d checks passed." % (len(_PASS), total))
for f in _FAIL:
print(" - " + f)
if _FAIL:
return 1
print("\nResult: " + _c("ALL PASS", "32") + " - the creator is a page.")
return 0
finally:
if server is not None:
try:
server.terminate()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())