Runs the app from a clean database, captures the before images, and records which of F1-F6 actually still reproduce. All six do. Rather than eyeball screenshots, each defect is measured in a browser by tests/f_items.py, which reports REPRODUCES / FIXED / INCONCLUSIVE and never a silent pass. That makes it both the wave 0 record and the wave 1-3 regression check: an item is done when its probe flips to FIXED. F1 hero says "Job A", app bar still says "Select a project", no reload F2 "Sign out" spans x382-432 in a 390px viewport - cut in half, 3 rows F3 chrome paints over the logo by 106x32px; .header-left collapses to 0 F4 comments drawer overlaps the header by 380x91px in the standalone creator F5 5 of 5 ENABLED wizard inputs compute #f4f4f4 on #e0e0e0 F6 11 cards in one 5,017px scroll, 0 tabs (review said ~4,700px; it grew) Three probes needed care to avoid reporting a false pass, and the traps are worth knowing before anyone verifies a fix: F1 disappears if localStorage is primed first, because then both sources of truth agree. The probe clears it and drives the real picker. F3 needs a long project name that is long IN THE DATABASE - any page reached with ?project= re-pulls it and overwrites a locally-faked one. It also cannot be measured by comparing .header-left to the chrome: under the long name .header-left (flex:1, min-width:0) collapses to clientWidth 0, so that comparison reports a tidy zero gap while the chrome paints across the logo. It measures against .logo, which is flex-shrink:0. My first two attempts at this probe both reported FIXED for those reasons; the screenshot did not. F5 must ignore genuinely disabled inputs or a fix looks done while real fields stay grey. 14 screenshots, not the 12 the plan asks for, because there are 7 pages (file-map D1). Capture also measures horizontal overflow, which is how BL-001 was found. Tooling: cdp.py gains viewport() and screenshot() - it could do neither, and T0.2 requires 390px and 1440px images. 390px sets the mobile flag rather than just narrowing the window, since every page declares width=device-width and Chrome otherwise lays out at 980px and no media query under test fires. Both new scripts reuse browser_check.py's seed() and start_server() instead of growing a second fixture. Existing browser_check still passes 71/71. No application code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
423 lines
20 KiB
Python
423 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Does each of F1-F6 still reproduce? Measured in a browser, not read from source.
|
|
|
|
T0.2 has to record reproduces / does not reproduce for every rendering defect before
|
|
wave 1 starts fixing them, and waves 1-3 have to prove each one stopped. Both halves
|
|
are the same measurement, so this is one script that reports either way:
|
|
|
|
REPRODUCES the defect is present -- correct before its fix lands
|
|
FIXED the defect is gone -- correct after
|
|
INCONCLUSIVE the probe could not decide; never silently a pass
|
|
|
|
python tests/f_items.py # all six
|
|
python tests/f_items.py F2 F5 # a subset, e.g. after one task
|
|
|
|
Self-contained on the same terms as tests/browser_check.py, whose seed() and
|
|
start_server() it reuses: throwaway SQLite, its own uvicorn, headless Edge or
|
|
Chrome, all torn down afterwards. Your real database is never touched.
|
|
|
|
Each probe states the threshold it applies, because "the drawer is off-screen" and
|
|
"the fields look disabled" are judgements a screenshot cannot settle in CI. Where the
|
|
review gave a number, that number is the threshold.
|
|
|
|
Exit codes: 0 ran to completion (read the report) - 2 could not run.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
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 # noqa: E402
|
|
|
|
# Project names and page copy contain em dashes and other non-cp1252 characters, and
|
|
# the default Windows console encoding raises UnicodeEncodeError on them mid-run.
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
except (AttributeError, ValueError): # pragma: no cover
|
|
pass
|
|
|
|
REPRO, FIXED, UNKNOWN = "REPRODUCES", "FIXED", "INCONCLUSIVE"
|
|
_RESULTS = []
|
|
|
|
# F3 only breaks with a real project name. "Micron EUV Cleanroom Enable 2667008" is
|
|
# the one the review used and the one that overflows the header.
|
|
LONG_ID = "projLong"
|
|
LONG_NAME = "Micron EUV Cleanroom Enable 2667008"
|
|
|
|
|
|
def add_long_project(db_path):
|
|
"""Add the long-named project to the seeded database, before the server starts.
|
|
|
|
It has to exist in the database rather than be faked in localStorage: every page
|
|
reached with ?project= re-pulls the project from the server, which would overwrite
|
|
a local fake and quietly measure the short name instead -- a false FIXED."""
|
|
os.environ["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
|
from server.db import SessionLocal
|
|
from server import models
|
|
with SessionLocal() as db:
|
|
db.add(models.Project(id=LONG_ID, name=LONG_NAME, number="2667008",
|
|
client="Micron"))
|
|
db.flush()
|
|
db.add(models.ProjectMember(id="pmLong", user_id="user_root",
|
|
project_id=LONG_ID, role=""))
|
|
db.commit()
|
|
|
|
|
|
def _c(s, code):
|
|
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
|
|
|
|
|
def report(item, verdict, detail):
|
|
colour = {REPRO: "31", FIXED: "32", UNKNOWN: "33"}[verdict]
|
|
_RESULTS.append((item, verdict, detail))
|
|
print(f" {item:3s} {_c(verdict, colour):22s} {detail}")
|
|
|
|
|
|
def rect(page, sel):
|
|
"""Bounding box of the first match, or None. Zero-size counts as absent."""
|
|
got = page.eval(
|
|
f"(function(){{var e=document.querySelector({sel!r});if(!e)return 'null';"
|
|
"var r=e.getBoundingClientRect();"
|
|
"return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height,"
|
|
"right:r.right,bottom:r.bottom,text:(e.textContent||'').trim().slice(0,60)});})()")
|
|
if not got or got == "null":
|
|
return None
|
|
r = json.loads(got)
|
|
return None if r["w"] == 0 and r["h"] == 0 else r
|
|
|
|
|
|
# ── F1 ────────────────────────────────────────────────────────────────────────
|
|
def f1(page, base, tok):
|
|
"""App bar stale while the page body shows the active project.
|
|
|
|
Driven through the real control: land on the launcher with storage cleared, pick a
|
|
project from the picker, and read both labels without reloading. That is the
|
|
interaction the review describes -- the hero and the picker update, the app bar
|
|
does not, because wp-chrome.js renders projectLabel() once and only refreshes it
|
|
on the /api/projects callback, never on the selection itself.
|
|
|
|
Priming localStorage before load would hide this, so it is cleared first."""
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.viewport(1440)
|
|
page.goto(base + "/index.html")
|
|
page.eval("localStorage.clear(); true")
|
|
page.goto(base + "/index.html", wait_for="!!document.querySelector('.wpc-proj-name')")
|
|
time.sleep(1.3) # let /api/projects land and wpcRefresh run
|
|
|
|
picked = page.eval("""(function(){
|
|
var s=document.querySelector('select'); if(!s) return 'no-select';
|
|
var o=[].slice.call(s.options).filter(function(x){return x.value==='projA';})[0];
|
|
if(!o) return 'no-projA';
|
|
s.value='projA'; s.dispatchEvent(new Event('change',{bubbles:true}));
|
|
return 'ok';})()""")
|
|
if picked != "ok":
|
|
return report("F1", UNKNOWN, f"could not drive the project picker ({picked})")
|
|
time.sleep(1.4) # generous: a correct fix may re-render async
|
|
|
|
bar = rect(page, ".wpc-proj-name")
|
|
hero = rect(page, "#hero-title")
|
|
if not bar or not hero:
|
|
return report("F1", UNKNOWN, "app bar or hero not found after selection")
|
|
stored = page.eval("localStorage.getItem('wp_active_project')")
|
|
reloaded = page.eval("location.search")
|
|
|
|
bar_txt, hero_txt = bar["text"], hero["text"]
|
|
body_knows = "Job A" in hero_txt
|
|
bar_knows = "Job A" in bar_txt
|
|
if body_knows and not bar_knows:
|
|
return report("F1", REPRO,
|
|
f"picked Job A: hero {hero_txt!r}, app bar still {bar_txt!r} "
|
|
f"(wp_active_project={stored!r}, no reload{', url ' + reloaded if reloaded else ''})")
|
|
if not body_knows:
|
|
return report("F1", UNKNOWN,
|
|
f"selection did not reach the hero either (hero {hero_txt!r}) - probe drove the wrong control")
|
|
return report("F1", FIXED,
|
|
f"app bar {bar_txt!r} tracks hero {hero_txt!r} in the same interaction")
|
|
|
|
|
|
# ── F2 ────────────────────────────────────────────────────────────────────────
|
|
def f2(page, base, tok):
|
|
"""App bar clips at 390px. The review names Sign out cut in half and search
|
|
truncated, on the field view. Threshold: any bar control whose box crosses the
|
|
viewport edge, or a bar that scrolls wider than it is."""
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.viewport(390, 900, mobile=True)
|
|
page.goto(base + "/field.html")
|
|
page.eval("localStorage.setItem('wp_active_project','projA');"
|
|
"localStorage.setItem('wp_active_project_obj',"
|
|
"JSON.stringify({id:'projA',name:'Job A',number:'A-1'})); true")
|
|
page.goto(base + "/field.html", wait_for="!!document.querySelector('.wp-appbar')")
|
|
time.sleep(0.8)
|
|
|
|
over = page.eval("""(function(){
|
|
var bar=document.querySelector('.wp-appbar'); if(!bar) return 'null';
|
|
var vw=document.documentElement.clientWidth, out=[];
|
|
bar.querySelectorAll('a,button,input').forEach(function(e){
|
|
var r=e.getBoundingClientRect();
|
|
if(r.width===0&&r.height===0) return;
|
|
if(r.right>vw+0.5||r.left<-0.5)
|
|
out.push(((e.textContent||e.placeholder||e.tagName).trim().slice(0,26))
|
|
+' @'+Math.round(r.left)+'-'+Math.round(r.right));
|
|
});
|
|
var br=bar.getBoundingClientRect();
|
|
return JSON.stringify({vw:vw, rows:Math.round(br.height/48), clipped:out,
|
|
scrollW:bar.scrollWidth, clientW:bar.clientWidth});})()""")
|
|
if over == "null":
|
|
return report("F2", UNKNOWN, ".wp-appbar not found on field.html")
|
|
m = json.loads(over)
|
|
if m["clipped"] or m["scrollW"] > m["clientW"]:
|
|
why = f"{len(m['clipped'])} control(s) past the {m['vw']}px edge"
|
|
if m["clipped"]:
|
|
why += ": " + "; ".join(m["clipped"][:3])
|
|
if m["scrollW"] > m["clientW"]:
|
|
why += f" | bar {m['scrollW']}px in {m['clientW']}px"
|
|
return report("F2", REPRO, why + f" | ~{m['rows']} row(s) tall")
|
|
return report("F2", FIXED, f"no control past {m['vw']}px, bar {m['rows']} row(s)")
|
|
|
|
|
|
# ── F3 ────────────────────────────────────────────────────────────────────────
|
|
def f3(page, base, tok):
|
|
""".header-left and the injected chrome overlap in the SOP header. Verified with
|
|
the real long project name, which is what the review says breaks it. Threshold:
|
|
the two boxes intersect by more than 2px on both axes."""
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.viewport(1440)
|
|
# Measure against the LOGO, not against .header-left. With the long name
|
|
# .header-left (flex:1, min-width:0) collapses to clientWidth 0, so comparing its
|
|
# box to the chrome's reports a tidy zero gap while the chrome is in fact painting
|
|
# straight across the logo. The logo is flex-shrink:0, so it is the one box in the
|
|
# bar whose position means something.
|
|
hits, measured, clear = [], 0, []
|
|
for w in (390, 768, 1024, 1440):
|
|
page.viewport(w, 900, mobile=(w <= 500))
|
|
page.goto(base + f"/work-package-suite.html?project={LONG_ID}",
|
|
wait_for="!!document.querySelector('.header-left')")
|
|
time.sleep(0.9)
|
|
got = page.eval("""(function(){
|
|
var hl=document.querySelector('.header-left'),
|
|
logo=document.querySelector('.header-left .logo'),
|
|
ch=document.querySelector('.wpc-proj')||document.querySelector('.wp-chrome');
|
|
if(!hl||!logo||!ch) return 'null';
|
|
var L=logo.getBoundingClientRect(), C=ch.getBoundingClientRect();
|
|
var ox=Math.min(C.right,L.right)-Math.max(C.left,L.left);
|
|
var oy=Math.min(C.bottom,L.bottom)-Math.max(C.top,L.top);
|
|
return JSON.stringify({
|
|
ox:Math.round(ox), oy:Math.round(oy),
|
|
collapsed: hl.clientWidth===0 && hl.scrollWidth>0,
|
|
hlsw:hl.scrollWidth, hlcw:hl.clientWidth,
|
|
name:((document.querySelector('.wpc-proj-name')||{}).textContent||'').trim()});})()""")
|
|
if got == "null":
|
|
continue
|
|
measured += 1
|
|
o = json.loads(got)
|
|
why = []
|
|
if o["ox"] > 1 and o["oy"] > 1:
|
|
why.append(f"chrome over the logo by {o['ox']}x{o['oy']}px")
|
|
if o["collapsed"]:
|
|
why.append(f".header-left collapsed to 0 (content {o['hlsw']}px)")
|
|
if why:
|
|
hits.append(f"{w}px: " + ", ".join(why))
|
|
else:
|
|
clear.append(f"{w}px")
|
|
if not measured:
|
|
return report("F3", UNKNOWN,
|
|
".header-left, its logo and the injected chrome were never all present")
|
|
if hits:
|
|
return report("F3", REPRO, "; ".join(hits))
|
|
return report("F3", FIXED,
|
|
f"logo clear of the chrome and .header-left intact at {', '.join(clear)} "
|
|
f"with the long project name")
|
|
|
|
|
|
# ── 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."""
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.viewport(1440)
|
|
page.goto(base + "/wp-creation-index.html?project=projA",
|
|
wait_for="!!document.querySelector('#cmt-drawer')")
|
|
time.sleep(0.8)
|
|
before = rect(page, "#cmt-drawer")
|
|
if not before:
|
|
return report("F4", UNKNOWN, "#cmt-drawer not found")
|
|
try:
|
|
page.click("#comments-btn", settle=0.9)
|
|
except Exception:
|
|
page.eval("typeof toggleComments==='function'?(toggleComments(),true):false")
|
|
time.sleep(0.9)
|
|
|
|
got = page.eval("""(function(){
|
|
var d=document.querySelector('#cmt-drawer'); if(!d) return 'null';
|
|
var r=d.getBoundingClientRect();
|
|
var vw=document.documentElement.clientWidth, vh=document.documentElement.clientHeight;
|
|
var open=d.classList.contains('open')||d.getAttribute('aria-hidden')==='false';
|
|
var hdr=document.querySelector('.wp-appbar,.header,header');
|
|
var ov=null;
|
|
if(hdr){var h=hdr.getBoundingClientRect();
|
|
var ox=Math.min(r.right,h.right)-Math.max(r.left,h.left);
|
|
var oy=Math.min(r.bottom,h.bottom)-Math.max(r.top,h.top);
|
|
if(ox>2&&oy>2) ov=Math.round(ox)+'x'+Math.round(oy);}
|
|
return JSON.stringify({open:open,x:Math.round(r.x),right:Math.round(r.right),
|
|
w:Math.round(r.width),vw:vw,vh:vh,offscreen:(r.right>vw+2||r.x<-2),overHeader:ov});})()""")
|
|
if got == "null":
|
|
return report("F4", UNKNOWN, "#cmt-drawer vanished after toggle")
|
|
d = json.loads(got)
|
|
if not d["open"]:
|
|
return report("F4", UNKNOWN, "drawer did not open; cannot judge placement")
|
|
bad = []
|
|
if d["offscreen"]:
|
|
bad.append(f"box {d['x']}-{d['right']} outside a {d['vw']}px viewport")
|
|
if d["overHeader"]:
|
|
bad.append(f"overlaps the header by {d['overHeader']}px")
|
|
if bad:
|
|
return report("F4", REPRO, "standalone creator: " + "; ".join(bad))
|
|
return report("F4", FIXED, f"drawer fully on screen ({d['x']}-{d['right']} of {d['vw']}px), clear of the header")
|
|
|
|
|
|
# ── F5 ────────────────────────────────────────────────────────────────────────
|
|
def f5(page, base, tok):
|
|
"""SOP wizard inputs read as read-only. The review names the exact colours:
|
|
#f4f4f4 fill, #e0e0e0 border, because the wizard never sees --cds-field #ffffff.
|
|
Threshold: an ENABLED input computing to that fill."""
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.viewport(1440)
|
|
page.goto(base + "/work-package-suite.html?project=projA",
|
|
wait_for="!!document.querySelector('.field input, .field select')")
|
|
time.sleep(0.9)
|
|
got = page.eval("""(function(){
|
|
var out=[], n=0;
|
|
document.querySelectorAll('.field input,.field select,.field textarea').forEach(function(e){
|
|
if(e.disabled||e.readOnly||e.type==='hidden'||e.type==='checkbox'||e.type==='radio') return;
|
|
var r=e.getBoundingClientRect(); if(r.width===0&&r.height===0) return;
|
|
n++;
|
|
var s=getComputedStyle(e);
|
|
out.push(s.backgroundColor+' | '+s.borderTopColor);
|
|
});
|
|
var tally={}; out.forEach(function(v){tally[v]=(tally[v]||0)+1;});
|
|
return JSON.stringify({n:n,tally:tally});})()""")
|
|
m = json.loads(got)
|
|
if not m["n"]:
|
|
return report("F5", UNKNOWN, "no enabled wizard inputs found")
|
|
GREY = ("rgb(244, 244, 244)", "rgb(243, 243, 243)")
|
|
greyed = sum(c for k, c in m["tally"].items() if any(g in k for g in GREY))
|
|
top = sorted(m["tally"].items(), key=lambda kv: -kv[1])[0]
|
|
if greyed:
|
|
return report("F5", REPRO,
|
|
f"{greyed}/{m['n']} enabled inputs fill #f4f4f4 (most common: {top[0]})")
|
|
return report("F5", FIXED, f"{m['n']} enabled inputs, none grey-filled (most common: {top[0]})")
|
|
|
|
|
|
# ── F6 ────────────────────────────────────────────────────────────────────────
|
|
def f6(page, base, tok):
|
|
"""The creator is one very tall form: the review counted 11 cards and ~4,700px
|
|
with jump links standing in for structure. Threshold: no sectioning control
|
|
(tabs/steps) and every card in the document flow at once."""
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.viewport(1440)
|
|
page.goto(base + "/wp-creation-index.html?project=projA",
|
|
wait_for="!!document.querySelector('.card, .section')")
|
|
time.sleep(1.0)
|
|
got = page.eval("""(function(){
|
|
var h=document.documentElement.scrollHeight;
|
|
var cards=document.querySelectorAll('.card,.wp-card,section.card').length;
|
|
var vis=0;
|
|
document.querySelectorAll('.card,.wp-card,section.card').forEach(function(e){
|
|
var s=getComputedStyle(e);
|
|
if(s.display!=='none'&&s.visibility!=='hidden') vis++;});
|
|
var tabs=document.querySelectorAll('[role=tab],.tab,.section-tab').length;
|
|
var jump=document.querySelectorAll('a[href^="#"]').length;
|
|
return JSON.stringify({h:h,cards:cards,vis:vis,tabs:tabs,jump:jump});})()""")
|
|
m = json.loads(got)
|
|
if m["vis"] > 1 and not m["tabs"] and m["h"] > 3000:
|
|
return report("F6", REPRO,
|
|
f"{m['vis']} cards in one {m['h']}px scroll, {m['tabs']} tabs, "
|
|
f"{m['jump']} jump link(s)")
|
|
if m["tabs"]:
|
|
return report("F6", FIXED,
|
|
f"{m['tabs']} sectioning control(s), {m['vis']} card(s) visible, {m['h']}px")
|
|
return report("F6", UNKNOWN,
|
|
f"{m['vis']} card(s), {m['h']}px, {m['tabs']} tabs - below the 3000px threshold")
|
|
|
|
|
|
PROBES = {"F1": f1, "F2": f2, "F3": f3, "F4": f4, "F5": f5, "F6": f6}
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("items", nargs="*", default=[], help="subset, e.g. F2 F5")
|
|
ap.add_argument("--base-url", default="")
|
|
args = ap.parse_args()
|
|
|
|
wanted = [i.upper() for i in args.items] or list(PROBES)
|
|
bad = [i for i in wanted if i not in PROBES]
|
|
if bad:
|
|
print(f"unknown item(s): {', '.join(bad)}; known: {', '.join(PROBES)}")
|
|
return 2
|
|
if not cdp.find_browser():
|
|
print("no headless-capable browser found (set WP_BROWSER)")
|
|
return 2
|
|
|
|
tmpdir = tempfile.mkdtemp(prefix="wpsuite-f-items-")
|
|
db_path = os.path.join(tmpdir, "f.db")
|
|
proc = browser = None
|
|
try:
|
|
tok = seed(db_path)
|
|
add_long_project(db_path)
|
|
if args.base_url:
|
|
base = args.base_url.rstrip("/")
|
|
else:
|
|
port = cdp.free_port()
|
|
proc = start_server(port, db_path)
|
|
base = f"http://127.0.0.1:{port}"
|
|
if not proc:
|
|
print("the server would not start")
|
|
return 2
|
|
|
|
print(f"\n probing {', '.join(wanted)}\n")
|
|
browser = cdp.Browser()
|
|
page = browser.page()
|
|
for item in wanted:
|
|
try:
|
|
PROBES[item](page, base, tok)
|
|
except Exception as exc: # noqa: BLE001
|
|
report(item, UNKNOWN, f"probe raised: {str(exc)[:120]}")
|
|
|
|
r = sum(1 for _, v, _ in _RESULTS if v == REPRO)
|
|
f = sum(1 for _, v, _ in _RESULTS if v == FIXED)
|
|
u = sum(1 for _, v, _ in _RESULTS if v == UNKNOWN)
|
|
print(f"\n {_c(str(r) + ' reproduce', '31')}, {_c(str(f) + ' fixed', '32')}"
|
|
+ (f", {_c(str(u) + ' inconclusive', '33')}" if u else "") + "\n")
|
|
return 0
|
|
finally:
|
|
if browser:
|
|
browser.close()
|
|
if proc:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
try:
|
|
from server.db import engine
|
|
engine.dispose()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|