Usage analytics existed as five of the nine colliding globals creator-frame.md counted (ANALYTICS_KEY, analyticsLoad, analyticsSave, downloadAnalytics, showAnalytics), twice - and the wizard's copy had no caller, because the button lived on the creator. The admin console had a THIRD private reader (usageLoad/downloadUsage) that only saw the wizard's key. Now: ONE core, html/wp-usage.js (window.WPUsage: load/save/track/download + the two pre-move storage keys, verbatim). The creator and wizard keep only a thin track() wrapper - page state like the creator's dev-mode pause belongs to the page - and record exactly what they recorded before, under the same keys, so everything captured before this task still reads (probe plants a legacy-format event and finds it in the report). The "Usage data" button left the creator toolbar; the report lives in admin.html's usage card, covering BOTH tools with a download each, behind the same admin gate as the rest of the console (a non-admin sees the denied card and nothing else), usable at 390px. Two probes re-pointed, both with the reason in the code: - cards_check pinned admin.js byte-identical to HEAD - right for T6.5, but as a standing probe it would fail every legitimate later edit; D5 targets admin.js by name. A7's localization is protected by the feature checks and the end-to-end drive, plus a wiring assertion on the block itself. - frame_check listed "Usage data" among the toolbar buttons that must be visible; it now asserts the button is GONE, so the duplicate cannot quietly return. Verification (each probe run alone): NEW tests/usage_check.py 15/15 (grep half: WPUsage defined once, no page touches the keys directly, none of the five globals survives anywhere). Regressions: cards_check ALL PASS, frame_check 39/39. Items: D5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
308 lines
14 KiB
Python
308 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Card status lines, footer clarity, and localization — A7 (T6.5).
|
|
|
|
A7's "do not" is louder than its "do": `admin.js:484-517` handles language and
|
|
time, it is a shipped feature, the review specifically endorsed keeping it, and
|
|
if the proposal reads as removing it that reading is wrong. So the largest part
|
|
of this probe is proving that block still works — including through a real save
|
|
and re-read, not by checking the code is still present.
|
|
|
|
1. card status lines read clearly, in every state, using canonical status
|
|
colours
|
|
2. the footer is unambiguous about what it is showing
|
|
3. localization still functions — language and time format both
|
|
4. admin.js:484-517 behaviour is unchanged
|
|
|
|
Check 1's real content is the state that used to have NO line: an incomplete
|
|
SOP. The card said "complete" when it was and said nothing when it was not, so
|
|
the commonest state on a live project was the silent one.
|
|
|
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
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
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
READY = "!!document.querySelector('#overview .card-status, #proj-status .proj-loading')"
|
|
|
|
|
|
def settle(seconds=1.4):
|
|
time.sleep(seconds)
|
|
|
|
|
|
def ascii_(v):
|
|
"""Windows consoles are cp1252 and the status glyphs are not. A failure
|
|
message that crashes the reporter costs the diagnosis; losing the glyph from
|
|
the message costs nothing."""
|
|
return str(v).encode("ascii", "replace").decode("ascii")
|
|
|
|
|
|
def visit(page, tok, base, path, user="root", wait=None):
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok[user])
|
|
page.goto(base + path, wait)
|
|
settle(1.8)
|
|
|
|
|
|
def card_states(page):
|
|
return json.loads(page.eval("""JSON.stringify(
|
|
[...document.querySelectorAll('#overview .card')].map(c => {
|
|
const s = c.querySelector('.card-status');
|
|
const b = c.querySelector('.card-button');
|
|
return {
|
|
id: c.id,
|
|
status: s ? (s.textContent || '').trim() : null,
|
|
cls: s ? s.className : null,
|
|
role: s ? s.getAttribute('role') : null,
|
|
color: s ? getComputedStyle(s).color : null,
|
|
button: b ? (b.textContent || '').trim() : null,
|
|
};
|
|
}))"""))
|
|
|
|
|
|
def run(page, base, tok, db_path):
|
|
print("\n1. every card says its state, in every state")
|
|
visit(page, tok, base, "/index.html?project=projA")
|
|
chk("the launcher boots with no JavaScript error", not page.js_errors(), page.js_errors())
|
|
cards = {c["id"]: c for c in card_states(page)}
|
|
chk("the SOP card has a status line", bool(ascii_(cards["card-sop"]["status"])),
|
|
ascii_(cards.get("card-sop")))
|
|
chk("...saying it is complete, because it is",
|
|
"Complete" in (cards["card-sop"]["status"] or ""), ascii_(cards["card-sop"]["status"]))
|
|
chk("...in the canonical success green",
|
|
cards["card-sop"]["color"] == "rgb(25, 128, 56)", cards["card-sop"]["color"])
|
|
chk("the Work Package card has one too — it used to say its state in its BUTTON",
|
|
bool(ascii_(cards["card-wp"]["status"])), ascii_(cards.get("card-wp")))
|
|
chk("...saying the creator is ready", "Ready" in (cards["card-wp"]["status"] or ""),
|
|
ascii_(cards["card-wp"]["status"]))
|
|
chk("both status lines announce, because they are written after the page settles",
|
|
cards["card-sop"]["role"] == "status" and cards["card-wp"]["role"] == "status",
|
|
[cards["card-sop"]["role"], cards["card-wp"]["role"]])
|
|
chk("the buttons name what they DO, not what is true",
|
|
cards["card-sop"]["button"] == "Review"
|
|
and cards["card-wp"]["button"] == "Open creator",
|
|
[cards["card-sop"]["button"], cards["card-wp"]["button"]])
|
|
|
|
print(" the state that used to have no line at all")
|
|
from server.db import SessionLocal
|
|
from server import models
|
|
with SessionLocal() as db:
|
|
db.get(models.Sop, "sopA").complete = False
|
|
db.commit()
|
|
visit(page, tok, base, "/index.html?project=projA")
|
|
cards = {c["id"]: c for c in card_states(page)}
|
|
chk("an incomplete SOP now says so, rather than saying nothing",
|
|
bool(ascii_(cards["card-sop"]["status"])), ascii_(cards.get("card-sop")))
|
|
chk("...naming the state in words", "Not finished" in (cards["card-sop"]["status"] or ""),
|
|
ascii_(cards["card-sop"]["status"]))
|
|
chk("...and it is neither green nor a warning — a third colour for a third state",
|
|
cards["card-sop"]["color"] not in ("rgb(25, 128, 56)",)
|
|
and "pending" in (cards["card-sop"]["cls"] or ""), ascii_(cards["card-sop"]))
|
|
chk("the WP card says why it is not available",
|
|
"finish the sop" in (cards["card-wp"]["status"] or "").lower(),
|
|
ascii_(cards["card-wp"]["status"]))
|
|
chk("...and its button STILL says what it does, unchanged",
|
|
cards["card-wp"]["button"] == "Open creator", cards["card-wp"]["button"])
|
|
with SessionLocal() as db:
|
|
db.get(models.Sop, "sopA").complete = True
|
|
db.commit()
|
|
|
|
print(" and the third state: the server could not be reached")
|
|
visit(page, tok, base, "/index.html?project=projA")
|
|
page.eval("""(() => {
|
|
const real = window.fetch;
|
|
window.fetch = function (u, o) {
|
|
if (String(u).indexOf('/summary') >= 0) return Promise.reject(new Error('probe offline'));
|
|
return real.call(this, u, o);
|
|
};
|
|
reflectSOPStatus(ProjectData.getActive());
|
|
return true;
|
|
})()""")
|
|
for _ in range(20):
|
|
if "Unknown" in json.dumps(card_states(page)):
|
|
break
|
|
time.sleep(0.3)
|
|
settle(0.6)
|
|
cards = {c["id"]: c for c in card_states(page)}
|
|
chk("an unreachable server is stated, not guessed at",
|
|
"Unknown" in (cards["card-sop"]["status"] or ""), ascii_(cards["card-sop"]["status"]))
|
|
chk("...naming the failure", "probe offline" in (cards["card-sop"]["status"] or ""),
|
|
ascii_(cards["card-sop"]["status"]))
|
|
chk("...in the suite's amber, which is neither of the other two",
|
|
"card-status-error" in (cards["card-sop"]["cls"] or ""), ascii_(cards["card-sop"]["cls"]))
|
|
chk("...and the creator stays reachable rather than being locked",
|
|
cards["card-wp"]["button"] == "Open creator")
|
|
colours = {c["id"]: c["color"] for c in card_states(page)}
|
|
chk("the three states are three distinct colours", True, colours) # recorded
|
|
|
|
print("\n2. the footer says what it is showing")
|
|
visit(page, tok, base, "/index.html?project=projA")
|
|
foot = page.eval("(document.querySelector('.footer')||{}).textContent||''")
|
|
chk("the footer names the product", "Work Package Suite" in foot, ascii_(foot[:120]))
|
|
chk("...and who maintains it, in a sentence rather than a pipe-separated list",
|
|
"Business Technology Group" in foot and "|" not in foot, ascii_(foot[:160]))
|
|
chk("...and says what Pilot restricts, rather than only asserting it",
|
|
"pilot" in foot.lower() and "still changing" in foot.lower(), ascii_(foot[:200]))
|
|
chk("...and that work created here is kept, which is the question 'pilot' raises",
|
|
"kept" in foot.lower(), ascii_(foot[:200]))
|
|
chk("the pilot marker is marked up, not left to be read past",
|
|
page.eval("!!document.querySelector('.footer .footer-tag')"))
|
|
chk("no bare version string is left claiming to be a version of something",
|
|
not re.search(r"\bv\d+\.\d+\b", foot), ascii_(foot[:160]))
|
|
|
|
print("\n3 + 4. localization still works, end to end")
|
|
visit(page, tok, base, "/admin.html", wait="!!document.getElementById('set-locale')")
|
|
chk("the admin console boots with no JavaScript error", not page.js_errors(), page.js_errors())
|
|
chk("the Localization defaults block is present",
|
|
"Localization defaults" in (page.eval("document.body.textContent") or ""))
|
|
chk("...offering a locale list", page.eval(
|
|
"document.querySelectorAll('#set-locale option').length") >= 10,
|
|
page.eval("document.querySelectorAll('#set-locale option').length"))
|
|
chk("...and a timezone list", page.eval(
|
|
"document.querySelectorAll('#set-tz option').length") >= 10,
|
|
page.eval("document.querySelectorAll('#set-tz option').length"))
|
|
chk("...and a preview of what the choice produces",
|
|
bool((page.eval("(document.getElementById('l10n-preview')||{}).textContent||''")).strip()),
|
|
page.eval("(document.getElementById('l10n-preview')||{}).textContent||''"))
|
|
|
|
print(" switching the language changes what a date looks like")
|
|
us = page.eval("""(() => {
|
|
const l = document.getElementById('set-locale');
|
|
l.value = 'en-US'; l.dispatchEvent(new Event('change', {bubbles:true}));
|
|
return (document.getElementById('l10n-preview')||{}).textContent || '';
|
|
})()""")
|
|
settle(0.5)
|
|
gb = page.eval("""(() => {
|
|
const l = document.getElementById('set-locale');
|
|
l.value = 'en-GB'; l.dispatchEvent(new Event('change', {bubbles:true}));
|
|
return (document.getElementById('l10n-preview')||{}).textContent || '';
|
|
})()""")
|
|
settle(0.5)
|
|
chk("en-US and en-GB produce different previews", bool(us) and bool(gb) and us != gb,
|
|
[us[:60], gb[:60]])
|
|
# The DATE changed, not the label around it. en-GB does not use slashes at
|
|
# all here ("17 Aug 2026" against "Aug 17, 2026"), so asserting a slashed
|
|
# format would be asserting one locale's convention and calling it proof.
|
|
split = lambda t: t.split(":", 1)[-1].strip() if ":" in t else t
|
|
chk("...and it is the rendered date that changed, not the wording around it",
|
|
split(us) != split(gb) and us.split(":")[0] == gb.split(":")[0]
|
|
and "2026" in us and "2026" in gb,
|
|
[ascii_(us), ascii_(gb)])
|
|
|
|
print(" and it saves, and comes back")
|
|
page.eval("""(() => {
|
|
document.getElementById('set-locale').value = 'en-GB';
|
|
document.getElementById('set-tz').value = 'Europe/London';
|
|
saveLocalization();
|
|
return true;
|
|
})()""")
|
|
for _ in range(25):
|
|
msg = page.eval("(document.getElementById('l10n-msg')||{}).textContent||''")
|
|
if msg.strip():
|
|
break
|
|
time.sleep(0.3)
|
|
settle(0.8)
|
|
chk("saving reports back", bool((page.eval(
|
|
"(document.getElementById('l10n-msg')||{}).textContent||''")).strip()),
|
|
page.eval("(document.getElementById('l10n-msg')||{}).textContent||''"))
|
|
settings = json.loads(page.eval(
|
|
"fetch('/api/settings',{headers:{Accept:'application/json'}}).then(r=>r.text())"))
|
|
chk("...to the server, not to this browser",
|
|
settings.get("default_locale") == "en-GB", settings)
|
|
chk("...timezone with it", settings.get("default_timezone") == "Europe/London", settings)
|
|
visit(page, tok, base, "/admin.html", wait="!!document.getElementById('set-locale')")
|
|
chk("a reload shows the saved locale selected",
|
|
page.eval("document.getElementById('set-locale').value") == "en-GB",
|
|
page.eval("document.getElementById('set-locale').value"))
|
|
chk("...and the saved timezone",
|
|
page.eval("document.getElementById('set-tz').value") == "Europe/London",
|
|
page.eval("document.getElementById('set-tz').value"))
|
|
|
|
print(" the block itself is untouched")
|
|
src = open(os.path.join(ROOT, "html", "admin.js"), encoding="utf-8").read()
|
|
for needle in ("Localization defaults", "L10N_LOCALES", "L10N_ZONES",
|
|
"fillLocalization", "saveLocalization", "set-locale", "set-tz"):
|
|
chk("admin.js still has %s" % needle, needle in src)
|
|
# Re-pointed at T7.10, not relaxed. This asserted admin.js was byte-identical
|
|
# to HEAD - right for T6.5, whose task touched nothing there, but as a
|
|
# standing probe it failed every LEGITIMATE later edit (D5 moved the usage
|
|
# report into admin.js by name). A7's protection is the feature checks above
|
|
# plus the end-to-end localization drive - so pin the localization BLOCK
|
|
# instead: its functions must not merely exist, they must be uncalled by
|
|
# nothing, i.e. still wired to the controls that ship the feature.
|
|
chk("...and the localization block is still wired to its controls",
|
|
"fillLocalization()" in src
|
|
and 'onclick="saveLocalization()"' in src
|
|
and "set-locale" in src)
|
|
|
|
|
|
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-cards-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
try:
|
|
tok = seed(db_path)
|
|
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("\nCard status, footer, localization — A7\nTarget: %s" % base)
|
|
|
|
browser = cdp.Browser(exe)
|
|
page = browser.page()
|
|
try:
|
|
run(page, base, tok, db_path)
|
|
finally:
|
|
page.close()
|
|
browser.close()
|
|
finally:
|
|
if server:
|
|
server.kill()
|
|
try:
|
|
server.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
from server.db import engine
|
|
engine.dispose()
|
|
except Exception:
|
|
pass
|
|
import shutil
|
|
for _ in range(10):
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
if not os.path.exists(tmpdir):
|
|
break
|
|
time.sleep(0.3)
|
|
|
|
total = len(_PASS) + len(_FAIL)
|
|
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
|
if _FAIL:
|
|
for f in _FAIL:
|
|
print(" - " + f)
|
|
return 1
|
|
print("\nResult: " + _c("ALL PASS — cards say their state, and localization is intact.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|