Files
Project-SDE-WP-Suite/tests/frame_check.py
n.siegfried b44afa7672 T9.4 - S7: one sample-data affordance, confirmed, and fenced off the project
Four affordances under three names became ONE: "Load sample data", on the
creator's toolbar, at the far end of two separators from the live actions
(New / Duplicate), pushed right with its own gap. It confirms through the
T7.9 dialog, naming exactly what it does - and what it does not: "This page
only: nothing is written to the project unless you then save." The probe
verifies the fence the way the done-when demands - against a REAL project,
reading the server's SOP and work-package list before and after and asserting
byte-identical.

Gone: the wizard's header "Load sample" (the dangerous one: it filled the
state completeSOP() pushes to the LIVE project, one click, no confirm, no
undo - reconciled with D1 exactly as the task records: the creator's control
is the survivor, the wizard copy goes), the creator's split Sample SOP /
Load example pair (now internals behind the one entry point), and the
empty-state context bar's third button (its text now points at the toolbar
control). The location/material "Load sample values" buttons stay: they fill
a PASTE BOX that acts only through an explicit, dry-runnable import - a
different thing, stated in the code.

Probes re-pointed with reasons in place: frame_check's D1 toolbar list names
the consolidated control; validation_check's sample-driven toast checks
became the-affordance-is-gone checks (and its stale showAnalytics drive,
orphaned by T7.10, became a the-duplicate-stays-gone check).

Verification (each probe run alone): NEW tests/sample_check.py 10/10.
Regressions: validation_check 77/77, frame_check 38/38, kitting_check 26/26,
export_check 20/20, sections_check 95/95.

Items: S7 (D1 reconciliation honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:05:56 -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))
# 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())