From 82a8f30074cafa09fd01c11777358153769c736e Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 19 Aug 2026 11:29:27 -0700 Subject: [PATCH] T7.10 - D5: one analytics implementation, and its report on the admin console 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 --- docs/reference/file-map.md | 1 + html/admin.html | 6 +- html/admin.js | 63 ++++++----- html/work-package-suite-app.js | 64 ++--------- html/work-package-suite.html | 1 + html/wp-creation-app.js | 13 +-- html/wp-creation-index.html | 2 +- html/wp-usage.js | 58 ++++++++++ tests/cards_check.py | 15 ++- tests/frame_check.py | 7 +- tests/usage_check.py | 192 +++++++++++++++++++++++++++++++++ 11 files changed, 321 insertions(+), 101 deletions(-) create mode 100644 html/wp-usage.js create mode 100644 tests/usage_check.py diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 88452c5..aa0f608 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -283,6 +283,7 @@ python tests/triage_check.py # A6 - the sidebar answers the stand-up python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks +python tests/usage_check.py # D5 - one analytics core, admin report 15 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/html/admin.html b/html/admin.html index e03d5a9..7a23417 100644 --- a/html/admin.html +++ b/html/admin.html @@ -178,10 +178,11 @@

Usage logs

-
Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on this machine.
+
Engagement recorded by both tools — the work package creator and the SOP wizard — + sessions, actions and counts, with a download per tool (D5). Note: stored locally per browser, + so this reflects activity on this machine.
-
Click refresh to load.
@@ -214,6 +215,7 @@ + + + - diff --git a/html/wp-usage.js b/html/wp-usage.js new file mode 100644 index 0000000..b4ee13a --- /dev/null +++ b/html/wp-usage.js @@ -0,0 +1,58 @@ +/* Usage analytics core — the ONE implementation (D5 / T7.10). + + This existed three times: the creator's copy, the wizard's copy (which had no + caller — the button lived on the creator), and the admin console's own reader. + Once the creator stopped being an iframe (B7/T7.1) the first two sat in one + document as five colliding globals; an unreferenced duplicate is exactly what + produced D5. One core now; the pages keep only a thin track() wrapper because + page state (the creator's dev-mode pause) belongs to the page. + + The storage KEYS are unchanged on purpose: everything recorded before this + file existed is still readable through it. No field VALUES are ever stored — + a field-edit event records the field id, nothing else. + + Classic script, no modules: exposes window.WPUsage. */ +'use strict'; + +(function () { + var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + + function load(key) { + try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; } + catch (e) { return { events: [] }; } + } + + function save(key, data) { + try { localStorage.setItem(key, JSON.stringify(data)); } + catch (e) { /* storage unavailable — degrade silently */ } + } + + function track(key, event, detail) { + try { + var d = load(key); + d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null }); + if (d.events.length > 5000) d.events = d.events.slice(-5000); + save(key, d); + } catch (e) { /* never let telemetry break the tool it watches */ } + } + + function download(key, prefix) { + var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); + } + + window.WPUsage = { + load: load, + save: save, + track: track, + download: download, + // The pre-D5 keys, verbatim — continuity of the recorded data is a done-when. + KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' }, + }; +})(); diff --git a/tests/cards_check.py b/tests/cards_check.py index 57815bf..2139b10 100644 --- a/tests/cards_check.py +++ b/tests/cards_check.py @@ -235,10 +235,17 @@ def run(page, base, tok, db_path): 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) - changed = subprocess.run( - ["git", "diff", "--stat", "HEAD", "--", "html/admin.js"], - cwd=ROOT, capture_output=True, text=True).stdout.strip() - chk("...and this task changed nothing in it at all", not changed, changed) + # 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(): diff --git a/tests/frame_check.py b/tests/frame_check.py index 4a6d5e6..2c9054c 100644 --- a/tests/frame_check.py +++ b/tests/frame_check.py @@ -244,8 +244,13 @@ def page_checks(page, base, tok): }); return JSON.stringify(out); })()""")) - for label in ("Sample SOP", "Load example", "View SOP", "Import SOP", "Usage data"): + # "Usage data" left this list at T7.10: D5 moved the report to the admin + # console. Its absence HERE is asserted, so the button cannot quietly return + # and recreate the duplicate D5 existed to remove. + for label in ("Sample SOP", "Load example", "View SOP", "Import SOP"): chk("%-13s is visible on the unframed page" % label, vis.get(label) is True, vis) + chk("Usage data is GONE from the creator toolbar (D5)", + "Usage data" not in vis, vis) chk("...and every one of them has a handler that exists", page.eval("""(() => { return [...document.querySelectorAll('#wp-toolbar button')].every(b => { const m = (b.getAttribute('onclick') || '').match(/^([A-Za-z_$][\\w$]*)\\(/); diff --git a/tests/usage_check.py b/tests/usage_check.py new file mode 100644 index 0000000..b99ca8f --- /dev/null +++ b/tests/usage_check.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Is there exactly one analytics implementation, reported from admin? — D5, T7.10. + +Usage analytics existed twice (creator + wizard), five of the nine colliding +globals `creator-frame.md` counted, and the wizard's copy had no caller. One +core survives in wp-usage.js; the pages keep thin track() wrappers; the report +and its downloads live on the admin console behind the same role gate as the +rest of that page. Keys are unchanged, so pre-move data still reads. + +Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome. +Exit 0 all passed, 1 a failure, 2 could not run. +""" +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 # noqa: E402 +from sections_check import set_sop # noqa: E402 +from stepper_check import dismiss_dialogs # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HTML = os.path.join(ROOT, "html") + + +def ascii_(v, n=280): + return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] + + +def settle(seconds=0.6): + time.sleep(seconds) + + +def strip_js(src): + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return "\n".join(re.sub(r"(? { + const d = JSON.parse(localStorage.getItem('wp_iwp_analytics_v1')) || {events: []}; + d.events.unshift({ts: '2026-07-01T10:00:00Z', session: 's_legacy', + event: 'legacy_probe_event', detail: null}); + localStorage.setItem('wp_iwp_analytics_v1', JSON.stringify(d)); + })()""") + n0 = page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length") + page.eval("track('probe_event')") + chk("the creator's track() still lands events under the old key", + page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length") == n0 + 1) + + page.goto(base + "/work-package-suite.html?tab=sop") + dismiss_dialogs(page) + settle(2.0) + n0 = page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length") + page.eval("track('probe_event_wizard')") + chk("the wizard's track() still lands events under its old key", + page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length") == n0 + 1) + wiz_errors = [e for e in page.js_errors() if "beforeunload" not in e] + chk("...and the wizard page throws no errors without its old globals " + "(the blocked-beforeunload console line is BL-020, filtered not hidden)", + not wiz_errors, ascii_(wiz_errors[:2])) + + # ── 3. the report, on admin, behind the admin gate ─────────────────── + print("\n3. the admin report") + page.goto(base + "/admin.html") + dismiss_dialogs(page) + settle(2.0) + page.eval("loadUsage()") + settle(0.5) + report = page.eval("(document.getElementById('usage-admin')||{textContent:''}).textContent") + chk("usage data is reachable from admin.html, both tools reported", + "Work package creator" in report and "SOP wizard" in report, ascii_(report, 160)) + chk("the pre-move legacy event is readable in the report", + "legacy_probe_event" in report) + chk("this session's fresh events are in it too", + "probe_event" in report and "probe_event_wizard" in report) + chk("each tool offers its download from the report", + page.eval("[...document.querySelectorAll('#usage-admin button')].length") >= 2) + + page.viewport(390, 844, mobile=True) + settle(0.6) + fits = page.eval("""(() => { + const box = document.getElementById('usage-admin'); + return box && box.scrollWidth <= box.clientWidth + 2 + && document.documentElement.scrollWidth <= 392; + })()""") + chk("the report is usable at 390px - no sideways scrolling", bool(fits)) + page.viewport(1440, 900) + settle(0.4) + + # The same role gate as the rest of the console: a non-admin sees the + # denied card and no cards, this one included. + page.clear_cookies() + page.set_cookie("wp_session", tok["pat"]) + page.goto(base + "/admin.html") + dismiss_dialogs(page) + settle(2.0) + chk("a non-administrator gets the denied notice, not the usage report", + page.eval("""(() => { + const denied = document.getElementById('admin-denied'); + const usage = document.getElementById('usage-admin'); + const visible = el => !!el && el.offsetParent !== null; + return visible(denied) && !visible(usage); + })()""")) + + js_errors = [e for e in page.js_errors() if "beforeunload" not in e] + chk("no JavaScript errors anywhere in this run", not js_errors, + ascii_(js_errors[:2])) + + finally: + if browser is not None: + try: + browser.close() + except Exception: + pass + if server is not None: + try: + server.terminate() + except Exception: + pass + + print("\n" + "-" * 54) + print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL))) + for f in _FAIL: + print(" - " + f) + return 1 if _FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main())