Files
Project-SDE-WP-Suite/tests/frame_check.py
n.siegfried 82a8f30074 T7.10 - D5: one analytics implementation, and its report on the admin console
Usage analytics existed as five of the nine colliding globals creator-frame.md
counted (ANALYTICS_KEY, analyticsLoad, analyticsSave, downloadAnalytics,
showAnalytics), twice - and the wizard's copy had no caller, because the
button lived on the creator. The admin console had a THIRD private reader
(usageLoad/downloadUsage) that only saw the wizard's key.

Now: ONE core, html/wp-usage.js (window.WPUsage: load/save/track/download +
the two pre-move storage keys, verbatim). The creator and wizard keep only a
thin track() wrapper - page state like the creator's dev-mode pause belongs
to the page - and record exactly what they recorded before, under the same
keys, so everything captured before this task still reads (probe plants a
legacy-format event and finds it in the report). The "Usage data" button left
the creator toolbar; the report lives in admin.html's usage card, covering
BOTH tools with a download each, behind the same admin gate as the rest of
the console (a non-admin sees the denied card and nothing else), usable at
390px.

Two probes re-pointed, both with the reason in the code:
- cards_check pinned admin.js byte-identical to HEAD - right for T6.5, but as
  a standing probe it would fail every legitimate later edit; D5 targets
  admin.js by name. A7's localization is protected by the feature checks and
  the end-to-end drive, plus a wiring assertion on the block itself.
- frame_check listed "Usage data" among the toolbar buttons that must be
  visible; it now asserts the button is GONE, so the duplicate cannot quietly
  return.

Verification (each probe run alone): NEW tests/usage_check.py 15/15 (grep
half: WPUsage defined once, no page touches the keys directly, none of the
five globals survives anywhere). Regressions: cards_check ALL PASS,
frame_check 39/39.

Items: D5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:29:27 -07:00

462 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.
for label in ("Sample SOP", "Load example", "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))
# Pinned, not fixed. BL-001 says to verify it at T7.1 and give it its own item
# if it survives the rebuild; T7.1 says to bundle nothing into this diff. So
# this asserts what is true TODAY and turns red the moment T7.2 lays the form
# out again - which is the point of pinning rather than printing.
chk("BL-001 is pinned: the creator still overflows at 390px, so this check "
"fails when it is fixed",
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())