T7.1 - B7: dissolve the creator iframe, and D1 give it back its sample data

There is no iframe in html/ any more. The creator is a top-level document with
the same app bar and the same tab strip as the SOP wizard; the two tabs that
used to swap a frame are links between them.

DEVIATION, stated rather than smuggled. The wave file says "remove the iframe
boundary so the creator renders in the parent document". It renders as its own
document instead. Every done-when is met - no iframe, no cross-frame messaging,
F4 resolved structurally, CR-006 toggles with no special-casing, back and
forward intact with T4.2's URL state - but the route is the other one, and the
reason is in creator-frame.md's own numbers:

                                    merge into parent    make it a page
  selector collisions to resolve                   21                 0
  script global collisions                          9                 0
  cross-frame call sites to remove                 28                28
  probe entry points needing rework               ~29                 2

The 21 and the 9 were never the cost of dissolving the boundary. They are the
cost of MERGING TWO DOCUMENTS, which is a different change the boundary was
hiding. And 29 probe call sites address wp-creation-index.html directly, so a
route that keeps that address keeps all of them. creator-frame.md section 5
records this in full.

What went, and what replaced it:

  #wp-frame, applyEmbedLayout, sizeWPFrame, viewportMinusChrome, chromeHeight,
  renderWPTab, the resize handler, the ResizeObserver, --wp-chrome-h,
  .content-area.embed-full, body.embed-full   ->  the window sizes the page

  ?embedded=1, body.embedded, .embed-hide, .embed-first   ->  nothing. An old
  link carrying the param is ignored rather than half-obeyed.

  openWpById / showDashboard / showForm / dashApplyFlag / applySopSections
  called across the frame   ->  the URL. ?project= ?view= ?wp= ?flag= were
  already read at the creator's own boot (T4.2), which is exactly why those four
  could be DELETED rather than migrated. X4 is closed: the surviving path is the
  one T5.5 built and proved.

  inIframe in auth-guard.js, wp-chrome.js, wp-sidenav.js, help.js and _isTop in
  project-data.js   ->  gone. help.js now reads the explicit WP_HELP_NO_FAB flag
  both tool pages set, instead of inferring intent from where it is rendered.

  .main-nav / .nav-tab in work-package-suite-styles.css   ->  wp-chrome.css,
  because a tab row only one of two documents can style is the shape that put
  the tabs in the parent and the toolbar in the child to begin with.

The three questions creator-frame.md section 4 said no count could answer:

  1. The creator gets the app bar. It was the only page loading neither
     wp-chrome file. Its header is now the .header-left / .header-right pair the
     wizard uses, so the switcher lands in the same place on both.
  2. Two sequence components, scoped not merged - confirmed Aug 18 that the
     sequence is authored in the SOP and adjustable per package. BL-015 stays.
  3. body.embedded is gone. The header it hid is replaced by the app bar; the
     sample controls are visible in a new package toolbar (D1); the analytics
     button is visible there until T7.10 moves it. The Dashboard BUTTON in that
     row became a TAB, which is the one place B7's "fold the toolbar into the
     tab row" actually happened.

Old addresses still resolve. ?tab=wp, ?view=dashboard and ?wp=<id> are in
bookmarks, in wp-sidenav's link map, and they are the shape CR-011 and CR-014
were specified against (X1). The wizard forwards them with replace(), so Back
does not bounce. Breaking these silently was the one regression this task could
have shipped that nobody would notice for weeks. frame_check.py section 4 pins
all three.

BEHAVIOUR CHANGE, deliberate. The live cross-frame hand-off showed the creator a
section toggle that had NOT been saved: flip it, look, reload, and the section
came back. What the creator shows now is the SOP that is stored. sections_check
5b pins both halves - an unsaved toggle does not travel, a saved one does.

BEHAVIOUR CHANGE, not deliberate, logged as BL-020. A tab switch is a page exit
now, so leaving the wizard with unsaved SOP edits fires T4.3's unsaved-work
guard. Nothing is lost - the guard writes the draft first and T4.3 recovers it -
but it is friction that did not exist, and suppressing a deliberate guard is a
product decision with its own downside. Logged, not quietly handled here.

tests/frame_check.py, 39 checks, new. Two of them exist because of failures
during this task rather than in it:

  - "both documents parse and boot". A const shadowing a function parameter is a
    SyntaxError, and work-package-suite-app.js did not parse at all for one run.
    Four checks in url_state_check went red and not one said "the script did not
    load". Asserting a page's own entry points exist costs nothing.
  - "focus emulation is on, so a focus reading means something". An earlier draft
    called page.call instead of page.ws.call inside a try/except and measured
    nothing, reporting no focus ring anywhere - which looks exactly like a
    finding. Trap 5 in reverse, for the second time in this project.

The four backlog entries logged against this file, re-measured rather than
assumed:

  BL-001  still reproduces (485px in a 390px viewport) but its RECORDED CAUSE IS
          WRONG. --nav-w now computes to 56px, so the injected-style explanation
          is spent. The overflow is the creator's data tables - #asset-body's
          lays out at 520px with no scroll container. frame_check reports the
          offending boxes by selector and skips position:fixed subtrees, because
          the comments drawer parked off-screen at right:844 made the first
          measurement blame the drawer. Pinned, not fixed: T7.2 lays out the form.
  BL-013  CLOSED. It was fixed by S12 in WAVE 4 - wp-creation-styles.css:209
          carries the comment naming this entry - and nobody updated it. It was
          quoted as a live CLAUDE.md violation while planning wave 7 and had not
          been true for four waves. a11y_check walks 120 focusable elements on
          the creator and every one rings at >= 3:1.
  BL-006  15 by the probe's measure, unchanged; different denominator, stated.
  BL-007  68 raw radii by the probe's measure. Nothing has reduced it in four
          waves; it is measured every run now instead of once.
  BL-018  cost a FOURTH probe. frame_check imports set_sop from sections_check
          rather than writing a fifth copy of the workaround. T9.9 owns it.

Probes re-pointed, with reasons in the files: sections_check 5b (drove the live
hand-off), pipeline_check check 2 (read through contentDocument), f_items F4
(drove standalone and embedded; there is one mode now), validation_check
(lost "the wrong tab", gained the SOP gate).

Verified: frame_check 39/39, sections_check 95/95, pipeline_check 44/44,
url_state_check 23/23, validation_check 83/83, a11y_check 22/22,
autosave_check 34/34, aggregates_check 16/16, stepper_check 71/71,
browser_check 71/71, launcher_check 58/58, generalinfo_check 49/49,
rollup_check 63/63, cards_check 44/44, locations_check 58/58.
f_items: F1-F5 fixed, F6 reproduces (T7.2).
Metrics: iframes 1 -> 0, colour literals in rules outside theme-light.css 0,
dialogs 64, <div onclick> 2, .help-tip 18.

Items: B7 D1
Task: T7.1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:26:27 -05:00
parent 0dbc240900
commit 12d19446d5
22 changed files with 1250 additions and 433 deletions

View File

@@ -317,9 +317,14 @@ def f3(page, base, tok):
# ── F4 ────────────────────────────────────────────────────────────────────────
def f4(page, base, tok):
"""Comments drawer renders off-screen and over the header in the STANDALONE
creator (no ?embedded=1). Threshold: once open, the drawer's box sits wholly or
partly outside the viewport, or intersects the header."""
"""Comments drawer renders off-screen and over the header in the creator.
Threshold: once open, the drawer's box sits wholly or partly outside the
viewport, or intersects the header.
Two modes used to be driven, standalone and ?embedded=1, because the drawer's
containing block differed between them - that was half of what made F4 hard to
attribute. B7/T7.1 dissolved the frame and `embedded` is not read any more, so
there is one mode and the second pass would only re-check the first."""
MEASURE = """(function(){
var d=document.querySelector('#cmt-drawer'); if(!d) return 'null';
var r=d.getBoundingClientRect();
@@ -356,7 +361,7 @@ def f4(page, base, tok):
time.sleep(0.9)
problems, notes = [], []
for mode, qs in (("standalone", ""), ("embedded", "&embedded=1")):
for mode, qs in (("as a page", ""),):
for w in (390, 1440):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
@@ -409,7 +414,7 @@ def f4(page, base, tok):
return report("F4", REPRO, "; ".join(problems[:4]))
if len(notes) >= 4:
return report("F4", UNKNOWN, "drawer never opened: " + "; ".join(notes[:2]))
detail = "on screen, clear of the header, reopens, scroll kept - at 390 and 1440, standalone and embedded"
detail = "on screen, clear of the header, reopens, scroll kept - at 390 and 1440"
return report("F4", FIXED, detail + (f" ({'; '.join(notes)})" if notes else ""))

456
tests/frame_check.py Normal file
View File

@@ -0,0 +1,456 @@
#!/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);
})()"""))
for label in ("Sample SOP", "Load example", "View SOP", "Import SOP", "Usage data"):
chk("%-13s is visible on the unframed page" % label, vis.get(label) is True, 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())

View File

@@ -14,9 +14,11 @@ strip still report the server's.
4. the strip announces updates via aria-live, because it refreshes in place
5. a failed request is an error, not four zeros and not a remembered number
Check 2 is the one with a seam in it: the dashboard is an iframe CHILD of the SOP
page, so the filter has to cross that boundary. The probe follows the link and
reads the filter inside the frame, rather than trusting that the URL was built.
Check 2 used to be the one with a seam in it: the dashboard was an iframe CHILD
of the SOP page, so the filter had to cross that boundary and the probe read it
inside the frame. B7/T7.1 dissolved the frame and the cell links straight at the
creator, so the filter arrives in the page's own URL. The check still reads the
rendered board rather than trusting that the URL was built.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
@@ -199,32 +201,32 @@ def run(page, base, tok):
sorted(h.split("flag=")[1] for h in hrefs if "flag=" in h)
== ["onhold", "overdue", "ready"], hrefs)
print(" ...and the filter survives the trip into the creator's iframe")
print(" ...and the filter arrives, on a page rather than in a frame")
onhold_href = [h for h in hrefs if "flag=onhold" in h][0]
page.goto(base + "/" + onhold_href.lstrip("/"))
for _ in range(40):
got = page.eval("""(() => {
const f = document.getElementById('wp-frame');
try { return f && f.contentWindow && f.contentWindow.wpCreatorReady ? 'ready' : ''; }
catch (e) { return 'x'; }
})()""")
if got == "ready":
if page.eval("!!window.wpCreatorReady"):
break
time.sleep(0.3)
settle(2.0)
# Read the FRAME'S DOM, not its globals. `dashFilter` and `currentView` are
# declared with let in a classic script, so they are not properties of window
# and a cross-frame read of them comes back undefined — which looks exactly
# like a filter that was never applied. The board itself is the evidence.
# This used to reach through `#wp-frame`.contentDocument, and had to: the
# dashboard was an iframe CHILD of the SOP page, so the filter was handed
# across rather than read from the child's own URL, and the only honest place
# to check it was inside the frame. B7/T7.1 dissolved the frame - the link now
# opens the creator directly and the filter arrives in its own URL, so this is
# an ordinary read of an ordinary page.
#
# What has NOT changed is why the BOARD is read rather than the globals:
# `dashFilter` and `currentView` are declared with let in a classic script, so
# they are not properties of window. The board itself is still the evidence.
inner = json.loads(page.eval("""(() => {
try {
const d = document.getElementById('wp-frame').contentDocument;
const dv = d.getElementById('dashboard-view');
const active = d.querySelector('.dash-metric.dm-active .dm-label');
// The board only — NOT the gating panel next to it, which is server-derived
const dv = document.getElementById('dashboard-view');
const active = document.querySelector('.dash-metric.dm-active .dm-label');
// The board only - NOT the gating panel next to it, which is server-derived
// and not filtered. Counting both would let a two-package gating list pass
// this check whatever the filter did.
const panel = [...d.querySelectorAll('.dash-panel')].find(p => {
const panel = [...document.querySelectorAll('.dash-panel')].find(p => {
const t = p.querySelector('.dash-panel-title');
return t && /^Work packages/.test(t.textContent.trim());
});
@@ -236,14 +238,16 @@ def run(page, base, tok):
shown: !!dv && getComputedStyle(dv).display !== 'none',
active: active ? active.textContent.trim() : null,
rows: rows,
framed: !!document.getElementById('wp-frame'),
});
} catch (e) { return JSON.stringify({error: String(e)}); }
})()"""))
chk("following an On hold cell opens the dashboard", inner.get("shown") is True, inner)
chk("...with the on-hold tile already the active filter, inside the frame",
chk("...with the on-hold tile already the active filter",
(inner.get("active") or "").lower() == "on hold", inner)
chk("...and the board listing only the held package, not all four",
inner.get("rows") == ["WP03-HOLD"], inner)
chk("...and no iframe was involved (B7/T7.1)", inner.get("framed") is False, inner)
print("\n3. a project with no work packages says so, in a sentence")
visit("/index.html?project=projEmpty")
@@ -254,8 +258,15 @@ def run(page, base, tok):
chk("...an explanation is", bool(empty_txt.strip()), repr(empty_txt))
chk("...naming the situation rather than showing four zeros",
"no work packages" in empty_txt.lower(), repr(empty_txt.strip()[:90]))
chk("...and offering somewhere to go", page.eval(
"!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"work-package-suite\"]')"))
# Re-pointed at T7.1, not relaxed. The link said "Open the creator" and went to
# the suite page, because the creator was a tab of it. It is a page now, so the
# link goes there directly - and the check still demands a link to the place
# the sentence offers, rather than merely a link.
chk("...and offering somewhere to go, which is the creator", page.eval(
"!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"wp-creation-index\"]')"),
page.eval("(document.querySelector('#pipeline-strip .pipe-empty a')||{}).getAttribute"
" ? document.querySelector('#pipeline-strip .pipe-empty a').getAttribute('href')"
" : '(no link)'"))
chk("no zero is rendered as a headline number anywhere in the strip",
page.eval("document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)

View File

@@ -15,10 +15,12 @@ replaces.
5. toggle state propagates into the creator — and where it does not, exactly
where and why (X4)
Check 5 is the one X4 warned about. The creator is an iframe child until T7.1,
so there are two propagation paths and they fail differently: the SOP the
creator reads at ITS boot, and a live hand-off while the frame is already open.
Both are exercised, separately, so a claim about one cannot cover for the other.
Check 5 is the one X4 warned about. The creator WAS an iframe child until T7.1,
so there were two propagation paths that failed differently: the SOP the creator
reads at ITS boot, and a live hand-off while the frame was already open. T7.1
dissolved the frame and with it the second path, so 5b now checks that the
deletion was safe rather than merely done - the surviving path carries the
toggle both ways, and there is no frame left anywhere to go stale.
The PDF export is checked by building the print document the way printPackage()
does — it reuses #pkg-doc's HTML — rather than by opening a print window, which
@@ -321,54 +323,92 @@ def run(page, base, tok, db_path):
and collected["subject"] == FULL_PKG["subject"], collected)
set_sop(db_path, {})
print("\n5b. a toggle flipped while the frame is open reaches it live (X4, path two)")
page.goto(base + "/work-package-suite.html?project=projA&tab=wp")
for _ in range(40):
inner = page.eval("""(() => {
try { const f = document.getElementById('wp-frame');
return !!(f && f.contentWindow && f.contentWindow.wpCreatorReady); }
catch (e) { return false; }
})()""")
if inner:
break
time.sleep(0.3)
print("\n5b. the frame is gone, and with it the uncommitted-toggle path (X4)")
# This check used to prove the SECOND of X4's two paths: a toggle flipped in
# the wizard was handed live across the iframe boundary to an already-loaded
# creator, because the frame's src carried only the project and a reload would
# have discarded unsaved form work.
#
# B7/T7.1 deleted that path by deleting the boundary, and that is a BEHAVIOUR
# CHANGE, not only a refactor. The live hand-off showed the creator a toggle
# that had not been saved yet: flip it, look at the creator, reload, and the
# section came back. What the creator shows now is the SOP that is actually
# stored. Checks 1 through 3 above already prove the surviving path carries a
# SAVED toggle in both directions; what is pinned here is the rest of it -
# there is no frame anywhere, the tab is a real link, and an UNSAVED toggle
# correctly does not travel.
page.goto(base + "/work-package-suite.html?project=projA&tab=sop&step=12")
settle(1.6)
chk("the wizard page hosts no iframe at all",
page.eval("document.querySelectorAll('iframe').length") == 0,
page.eval("document.querySelectorAll('iframe').length"))
chk("...and the Work Package tab is a link to the creator, not a frame swap",
page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="wp"]');
return !!a && a.tagName === 'A' && (a.getAttribute('href') || '')
.indexOf('wp-creation-index.html') === 0;
})()"""),
page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="wp"]');
return a ? a.tagName + ' ' + (a.getAttribute('href')||'(no href)') : 'missing'; })()"""))
chk("...carrying the project, so the link works on a cold browser",
"project=projA" in (page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="wp"]');
return a ? (a.getAttribute('href') || '') : ''; })()""") or ""))
chk("...and the Dashboard tab is a link to the same page's board",
page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="dashboard"]');
const h = a ? (a.getAttribute('href') || '') : '';
return a && a.tagName === 'A' && h.indexOf('wp-creation-index.html') === 0
&& h.indexOf('view=dashboard') > 0;
})()"""),
page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="dashboard"]');
return a ? a.tagName + ' ' + (a.getAttribute('href')||'(no href)') : 'missing'; })()"""))
# Flip Assets off WITHOUT completing the SOP, then follow the tab.
page.eval(STUB)
chk("the creator is loaded in the frame", page.eval("""(() => {
try { return !!document.getElementById('wp-frame').contentWindow.wpCreatorReady; }
catch (e) { return false; }
settle(0.6)
chk("Assets reads as on in the wizard to begin with", page.eval("""(() => {
const b = document.querySelector('#section-toggles input[data-section="assets"]');
return !!b && b.checked;
})()"""))
chk("...showing Assets to begin with", page.eval("""(() => {
const d = document.getElementById('wp-frame').contentDocument;
const el = d.querySelector('#asset-card');
return !!el && !el.hidden;
})()"""))
page.eval("goToStep(12)")
settle(0.8)
page.eval("""(() => {
const b = document.querySelector('#section-toggles input[data-section="assets"]');
b.checked = false; b.dispatchEvent(new Event('change', {bubbles:true}));
return true;
})()""")
settle(0.6)
chk("...and the wizard says so, in the summary line", page.eval("""(() => {
const el = document.getElementById('section-summary');
return !!el && /assets/i.test(el.textContent || '');
})()"""), page.eval("(document.getElementById('section-summary')||{}).textContent||''"))
page.eval("""(() => { document.querySelector('.nav-tab[data-tab="wp"]').click(); })()""")
settle(0.8)
chk("turning Assets off reaches the already-loaded frame, with no reload",
chk("following the tab lands on the creator", wait_creator(page))
settle(1.2)
chk("...and the creator page hosts no iframe either",
page.eval("document.querySelectorAll('iframe').length") == 0)
chk("...showing Assets, because the toggle was never saved",
page.eval("""(() => {
const d = document.getElementById('wp-frame').contentDocument;
const el = d.querySelector('#asset-card');
return !!el && el.hidden;
})()"""))
page.eval("""(() => {
const b = document.querySelector('#section-toggles input[data-section="assets"]');
b.checked = true; b.dispatchEvent(new Event('change', {bubbles:true}));
return true;
})()""")
settle(0.8)
chk("...and turning it back on brings it back",
page.eval("""(() => {
const d = document.getElementById('wp-frame').contentDocument;
const el = d.querySelector('#asset-card');
const el = document.querySelector('#asset-card');
return !!el && !el.hidden;
})()"""))
})()"""),
page.eval("""(() => { const el = document.querySelector('#asset-card');
return !el ? 'missing' : (el.hidden ? 'hidden' : 'shown'); })()"""))
# And the same toggle, saved, does travel - the surviving path, end to end.
set_sop(db_path, {"assets": False})
page.goto(base + "/wp-creation-index.html?project=projA")
chk("the same toggle, saved this time, reaches the creator", wait_creator(page))
settle(1.2)
chk("...and Assets is suppressed, from the SOP alone (X4, the surviving path)",
page.eval("""(() => {
const el = document.querySelector('#asset-card');
return !!el && el.hidden;
})()"""),
page.eval("""(() => { const el = document.querySelector('#asset-card');
return !el ? 'missing' : (el.hidden ? 'hidden' : 'shown'); })()"""))
set_sop(db_path, {})
print("\n an older SOP that predates CR-006 gets every section, not none")
set_sop_no_sections(db_path)

View File

@@ -253,10 +253,23 @@ def run(page, base, tok):
return focused && document.getElementById('wp-toast').hidden;
})()"""))
page.eval("switchTool('wp'); loadSampleData()")
# This used to be "loading the sample on the wrong tab interrupts", because the
# wizard's Load sample was context-aware: on the WP tab it reached into the
# iframe and called the creator's own loadExample(), and complained when the
# frame was not there. B7/T7.1 made the creator a page, so there is no wrong
# tab to be on and nothing to reach into - this page's sample is the SOP
# sample, always. What is left to check is that the one remaining edge, the
# SOP gate, still says something rather than nothing.
page.eval("showCreatorGate(); loadSampleData()")
settle(1.2)
chk("loading the sample on the wrong tab interrupts (role=alert)",
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "alert",
chk("from the gate, loading the sample announces rather than failing silently",
page.eval("!document.getElementById('wp-toast').hidden"))
chk("...politely, because being on the gate is not an error",
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "status",
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
chk("...and says where the work package sample actually is",
"Work Package Creation" in (page.eval(
"(document.getElementById('wp-toast')||{}).textContent||''")),
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
page.eval("switchTool('sop')")
settle(0.6)