INTERIM. T3.4 removes the duplicate token underneath this; the job here is only the appearance, and no token consolidation is started. The wizard filled its inputs with var(--bg) - which in this sheet is the PAGE BACKGROUND, #f4f4f4 - on a #e0e0e0 border. An empty required field was indistinguishable from a locked one, which is why people were not typing in them. The cause is the one the review named: this sheet redeclares its own tokens, so it never saw --cds-field: #ffffff, even though theme-light.css has been supplying that to this page all along. Fields now consume --cds-field, and take the same --border-strong the creator's inputs already use, so a field looks like a field on both pages. No new value is introduced - both tokens already existed. That inverts a signal if left there, so it needed the other half: there was no disabled rule at all on this page, meaning locked fields would have turned white too. Disabled and readonly fields now take --cds-field-02, the theme's own secondary field surface, matching .locked-field in the creator. Enabled #ffffff against disabled #f4f4f4, verified by computed style rather than by eye. The border is deliberately the same on both states. I first wrote `border-color: var(--border)` on the disabled rule and could not demonstrate it taking effect - the rule matches, is more specific than the base rule, and its background applies, but the computed border stayed --border-strong. Rather than ship a declaration whose effect I cannot show, it is gone: a consistent border is what "consistent with inputs elsewhere" asks for, and the fill is what carries the state. Screenshot diff is limited to the wizard, but establishing that took a control run. admin and users appeared to change too, until capturing twice with NO code change showed they differ from themselves - the console pages render live timestamps and are not byte-stable. login, launcher, sop, creator and field are. Recorded in the baseline README so the next task with a "no layout change" done-when does not chase it. The F5 probe now also fails if enabled and disabled fields become identical, which is the way this fix could silently go wrong. f_items: F1-F5 FIXED, F6 untouched as wave 1 requires. browser_check 71/71. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
518 lines
25 KiB
Python
518 lines
25 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 hdr=document.querySelector('.header'),
|
|
hl=document.querySelector('.header-left'),
|
|
logo=document.querySelector('.header-left .logo');
|
|
if(!hdr||!hl||!logo) return 'null';
|
|
// Every group sharing this bar, not just the two the review named: the
|
|
// markup's own, plus what wp-chrome.js and auth-guard.js inject into it.
|
|
var boxes=[];
|
|
['.header-left','.wp-chrome','.header-right','#wp-usermenu'].forEach(function(s){
|
|
var e=hdr.querySelector(s); if(!e) return;
|
|
var r=e.getBoundingClientRect();
|
|
if(r.width>0||r.height>0) boxes.push({s:s,r:r});
|
|
});
|
|
var pairs=[];
|
|
for(var i=0;i<boxes.length;i++) for(var j=i+1;j<boxes.length;j++){
|
|
var A=boxes[i].r,B=boxes[j].r;
|
|
var ox=Math.min(A.right,B.right)-Math.max(A.left,B.left);
|
|
var oy=Math.min(A.bottom,B.bottom)-Math.max(A.top,B.top);
|
|
if(ox>1&&oy>1) pairs.push(boxes[i].s+'/'+boxes[j].s+' '+Math.round(ox)+'x'+Math.round(oy));
|
|
}
|
|
// The logo is flex-shrink:0 and keeps its width even when its parent is
|
|
// crushed, so check it against the chrome directly too.
|
|
var ch=hdr.querySelector('.wpc-proj')||hdr.querySelector('.wp-chrome');
|
|
var lox=0,loy=0;
|
|
if(ch){var L=logo.getBoundingClientRect(),C=ch.getBoundingClientRect();
|
|
lox=Math.min(C.right,L.right)-Math.max(C.left,L.left);
|
|
loy=Math.min(C.bottom,L.bottom)-Math.max(C.top,L.top);}
|
|
// Anything spilling past the bar lands on the tab row underneath it.
|
|
var H=hdr.getBoundingClientRect(), spill=0;
|
|
[].forEach.call(hdr.children,function(e){
|
|
var r=e.getBoundingClientRect();
|
|
if(r.width===0&&r.height===0) return;
|
|
if(r.bottom>H.bottom+1||r.top<H.top-1) spill++;
|
|
});
|
|
return JSON.stringify({
|
|
pairs:pairs, ox:Math.round(lox), oy:Math.round(loy), spill:spill,
|
|
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 o["pairs"]:
|
|
why.append("overlapping groups: " + "; ".join(o["pairs"]))
|
|
if o["spill"]:
|
|
why.append(f"{o['spill']} group(s) spilling out of the bar")
|
|
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."""
|
|
MEASURE = """(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('.header,.wp-appbar,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);}
|
|
// The head carries the title and the close button. If it is not hit-testable
|
|
// the drawer is unusable however tidy its box looks.
|
|
var head=d.querySelector('.cmt-head'), x=d.querySelector('.cmt-x'), reach=null;
|
|
if(x){var q=x.getBoundingClientRect();
|
|
var top=document.elementFromPoint(Math.round(q.left+q.width/2), Math.round(q.top+q.height/2));
|
|
reach = !!(top && (top===x || x.contains(top)));}
|
|
// A page whose layout viewport is wider than the screen puts every `right:0`
|
|
// fixed element off the visible area, drawer included. That is the page's
|
|
// overflow, not the drawer's placement, so it is reported separately -
|
|
// blaming the drawer for it sends the next person to the wrong file.
|
|
var icb=window.innerWidth;
|
|
return JSON.stringify({open:open,x:Math.round(r.x),right:Math.round(r.right),
|
|
top:Math.round(r.top),bottom:Math.round(r.bottom),vw:vw,vh:vh,icb:icb,
|
|
offscreen:(r.right>icb+2||r.x<-2||r.top<-2||r.bottom>vh+2),
|
|
overHeader:ov, closeReachable:reach,
|
|
headH: head?Math.round(head.getBoundingClientRect().height):0});})()"""
|
|
|
|
def toggle():
|
|
try:
|
|
page.click("#comments-btn", settle=0.9)
|
|
except Exception:
|
|
page.eval("typeof toggleComments==='function'?(toggleComments(),true):false")
|
|
time.sleep(0.9)
|
|
|
|
problems, notes = [], []
|
|
for mode, qs in (("standalone", ""), ("embedded", "&embedded=1")):
|
|
for w in (390, 1440):
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.viewport(w, 900, mobile=(w <= 500))
|
|
page.goto(base + f"/wp-creation-index.html?project=projA{qs}",
|
|
wait_for="!!document.querySelector('#cmt-drawer')")
|
|
time.sleep(1.0)
|
|
# Scroll down first: the drawer must not cost the user their place.
|
|
page.eval("window.scrollTo(0, 600); true")
|
|
time.sleep(0.3)
|
|
before_y = page.eval("Math.round(window.scrollY)")
|
|
|
|
toggle()
|
|
m = page.eval(MEASURE)
|
|
if m == "null":
|
|
problems.append(f"{mode}@{w}: #cmt-drawer missing")
|
|
continue
|
|
d = json.loads(m)
|
|
if not d["open"]:
|
|
notes.append(f"{mode}@{w}: did not open")
|
|
continue
|
|
if d["offscreen"]:
|
|
problems.append(f"{mode}@{w}: box {d['x']}-{d['right']} x {d['top']}-{d['bottom']} "
|
|
f"outside {d['icb']}x{d['vh']}")
|
|
elif d["icb"] > w + 2:
|
|
# Compared against the width we ASKED for, not clientWidth, which is
|
|
# short by the scrollbar and would flag every page as overflowing.
|
|
notes.append(f"{mode}@{w}: correctly at the right edge of a {d['icb']}px layout "
|
|
f"viewport, but the screen is {w}px - page overflow, BL-001")
|
|
if d["overHeader"]:
|
|
problems.append(f"{mode}@{w}: overlaps the header by {d['overHeader']}px")
|
|
if d["closeReachable"] is False:
|
|
problems.append(f"{mode}@{w}: close button covered by another element")
|
|
|
|
# close, reopen, and check the page did not jump
|
|
toggle()
|
|
time.sleep(0.4)
|
|
toggle()
|
|
m2 = json.loads(page.eval(MEASURE))
|
|
if not m2["open"]:
|
|
problems.append(f"{mode}@{w}: did not reopen")
|
|
elif m2["offscreen"] or m2["overHeader"]:
|
|
problems.append(f"{mode}@{w}: misplaced after reopen")
|
|
after_y = page.eval("Math.round(window.scrollY)")
|
|
if abs(after_y - before_y) > 4:
|
|
problems.append(f"{mode}@{w}: scroll moved {before_y}->{after_y}")
|
|
toggle()
|
|
|
|
if problems:
|
|
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"
|
|
return report("F4", FIXED, detail + (f" ({'; '.join(notes)})" if notes else ""))
|
|
|
|
|
|
# ── 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, dis=[];
|
|
document.querySelectorAll('.field input,.field select,.field textarea').forEach(function(e){
|
|
if(e.type==='hidden'||e.type==='checkbox'||e.type==='radio') return;
|
|
var r=e.getBoundingClientRect(); if(r.width===0&&r.height===0) return;
|
|
var s=getComputedStyle(e), sig=s.backgroundColor+' | '+s.borderTopColor;
|
|
if(e.disabled||e.readOnly){ dis.push(sig); return; }
|
|
n++; out.push(sig);
|
|
});
|
|
var tally={}; out.forEach(function(v){tally[v]=(tally[v]||0)+1;});
|
|
var dtally={}; dis.forEach(function(v){dtally[v]=(dtally[v]||0)+1;});
|
|
// Prove the disabled rule works even when the page has no disabled field to
|
|
// sample: borrow a real one, flip it, measure, put it back.
|
|
var probe=document.querySelector('.field input:not([type=hidden])'), forced=null;
|
|
if(probe){ var was=probe.disabled; probe.disabled=true;
|
|
var ps=getComputedStyle(probe);
|
|
forced=ps.backgroundColor+' | '+ps.borderTopColor; probe.disabled=was; }
|
|
return JSON.stringify({n:n,tally:tally,dis:dtally,forced:forced});})()""")
|
|
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]})")
|
|
# An enabled field that looks exactly like a disabled one is the same defect
|
|
# inverted, so this must not silently pass.
|
|
if m["forced"] and m["forced"] == top[0]:
|
|
return report("F5", REPRO,
|
|
f"enabled and disabled fields are identical ({top[0]}) - "
|
|
f"locked fields no longer read as locked")
|
|
extra = f", disabled reads {m['forced']}" if m["forced"] else ""
|
|
return report("F5", FIXED,
|
|
f"{m['n']} enabled inputs at {top[0]}{extra}")
|
|
|
|
|
|
# ── 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())
|