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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:29:27 -07:00
parent e3de3c7c00
commit 82a8f30074
11 changed files with 321 additions and 101 deletions

View File

@@ -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/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/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/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 **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -178,10 +178,11 @@
<!-- USAGE LOGS --> <!-- USAGE LOGS -->
<div class="card"> <div class="card">
<h2>Usage logs</h2> <h2>Usage logs</h2>
<div class="sub">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div> <div class="sub">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 <strong>this</strong> machine.</div>
<div class="toolbar"> <div class="toolbar">
<button onclick="loadUsage()">Refresh</button> <button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div> </div>
<div id="usage-admin" class="note">Click refresh to load.</div> <div id="usage-admin" class="note">Click refresh to load.</div>
</div> </div>
@@ -214,6 +215,7 @@
</div> </div>
</div> </div>
<script src="wp-usage.js"></script>
<script src="console-util.js"></script> <script src="console-util.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this <!-- The app bar's project switcher reads ProjectData; without this the bar on this

View File

@@ -649,39 +649,46 @@ async function loadNotifications(){
} }
// ── usage logs (read from this browser's localStorage) ────────────────────────── // ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1'; // D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } } // operator-facing readout belongs - behind the same admin gate as this whole
// page (gateByRole() below shows nothing else either). Data comes from
// wp-usage.js, the single implementation; the keys predate the move, so
// everything recorded before it is still here.
function loadUsage(){ function loadUsage(){
const box = document.getElementById('usage-admin'); const box = document.getElementById('usage-admin');
const evs = (usageLoad().events) || []; if(!box) return;
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; } const tools = [
const byEvent = {}, byStep = {}, sessions = new Set(); ['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
];
let html = '';
tools.forEach(([label, key, prefix]) => {
const evs = (WPUsage.load(key).events) || [];
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
if(!evs.length){
html += '<div class="note">No usage recorded in this browser yet.</div>';
return;
}
const byEvent = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts; let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => { evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1; byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session); if(e.session) sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts; if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
}); });
const fmt = s => s ? wpFormatDateTime(s) : '—'; const fmt = v => v ? wpFormatDateTime(v) : '—';
let html = '<table class="kv">'+ html += '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+ '<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+ '<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>'; '<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>'; html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
html += '</tbody></table>';
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>'); Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>'; html += '</tbody></table>';
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
});
box.innerHTML = html; box.innerHTML = html;
} }
function downloadUsage(){
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
}
// ── access control: admins only ───────────────────────────────────────────────── // ── access control: admins only ─────────────────────────────────────────────────
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready'). // auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').

View File

@@ -2337,69 +2337,19 @@ function loadStepComments(){
// Lightweight usage analytics stored in localStorage so the tool owner can review // Lightweight usage analytics stored in localStorage so the tool owner can review
// engagement over time. No field VALUES are stored (field-edit events record only // engagement over time. No field VALUES are stored (field-edit events record only
// the field id), keeping captured data non-sensitive. // the field id), keeping captured data non-sensitive.
const ANALYTICS_KEY = 'wp_suite_analytics_v1'; // D5 / T7.10: the analytics implementation lives in wp-usage.js and the report
// on the admin console. The wizard's own copy of showAnalytics() never had a
// caller here - the button lived on the creator - and once B7 dissolved the
// frame the duplicate sat in the same document as five colliding globals.
// This page only records; dwell tracking keeps its page-local state below.
let _stepEnter = Date.now(); let _stepEnter = Date.now();
const _session = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6);
function analyticsLoad(){
try { return JSON.parse(localStorage.getItem(ANALYTICS_KEY)) || {events:[]}; }
catch(e){ return {events:[]}; }
}
function analyticsSave(data){
try { localStorage.setItem(ANALYTICS_KEY, JSON.stringify(data)); }
catch(e){ /* storage unavailable — degrade silently */ }
}
function track(event, detail){ function track(event, detail){
try { WPUsage.track(WPUsage.KEYS.wizard, event, detail);
const data = analyticsLoad();
data.events.push({ ts: new Date().toISOString(), session: _session, event, detail: detail||null });
if(data.events.length > 5000) data.events = data.events.slice(-5000);
analyticsSave(data);
} catch(e){}
} }
function trackStepDwell(){ function trackStepDwell(){
const ms = Date.now() - _stepEnter; const ms = Date.now() - _stepEnter;
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms}); if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
_stepEnter = Date.now(); _stepEnter = Date.now();
} }
function analyticsSummary(){
const data = analyticsLoad();
const byEvent = {}, byStep = {}, dwell = {}, sessions = new Set();
data.events.forEach(e=>{
byEvent[e.event] = (byEvent[e.event]||0)+1;
sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step]=(byStep[e.detail.step]||0)+1;
if(e.event==='step_dwell' && e.detail){ dwell[e.detail.step]=(dwell[e.detail.step]||0)+e.detail.ms; }
});
return {total:data.events.length, sessions:sessions.size, byEvent, byStep, dwell, first:data.events[0]?.ts, last:data.events[data.events.length-1]?.ts};
}
function showAnalytics(){
const s = analyticsSummary();
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'}${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
for(let i=1;i<=LAST_STEP;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
txt += `\nActions:\n`;
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
// Was a native confirmation carrying the whole summary as its body — a wall of
// text in a dialog with one OK button. The summary is the useful part, so it is
// shown, and the download is offered as an action beside it rather than as the
// only way to dismiss the message.
//
// Note this function has no caller in the wizard's markup: the "Usage data"
// button lives on the creator, which has its own showAnalytics(). Converted
// rather than deleted because deleting a feature is not what T5.8 was asked to
// do, and its dialog counts toward the number this task has to drive to zero.
wizardToast(txt.replace(/\n+/g, ' · ').trim(),
{action: {label: 'Download the full event log', fn: downloadAnalytics}});
}
function downloadAnalytics(){
const data = analyticsLoad();
const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
URL.revokeObjectURL(a.href);
track('analytics_exported');
}

View File

@@ -11,6 +11,7 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL <!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. --> during their own boot. -->
<script src="wp-url.js"></script> <script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). --> <!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script> <script src="wp-autosave.js"></script>
<!-- Which work package sections this project uses (CR-006). Shared with the <!-- Which work package sections this project uses (CR-006). Shared with the

View File

@@ -3173,14 +3173,11 @@ function openSopModal(){
document.getElementById('sop-modal').classList.add('open'); track('view_sop'); document.getElementById('sop-modal').classList.add('open'); track('view_sop');
} }
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); } function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
const ANALYTICS_KEY='wp_iwp_analytics_v1'; const _session='s_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); // D5 / T7.10: the analytics implementation lives in wp-usage.js - ONE copy for
function analyticsLoad(){ try{ return JSON.parse(localStorage.getItem(ANALYTICS_KEY))||{events:[]}; }catch(e){ return {events:[]}; } } // the whole suite - and its report lives on the admin console, where an
function analyticsSave(d){ try{ localStorage.setItem(ANALYTICS_KEY, JSON.stringify(d)); }catch(e){} } // operator-facing readout belongs. This page only records. Same key, same
function track(event,detail){ if(devMode) return; try{ const d=analyticsLoad(); d.events.push({ts:new Date().toISOString(),session:_session,event,detail:detail||null}); if(d.events.length>5000)d.events=d.events.slice(-5000); analyticsSave(d); }catch(e){} } // event shape: everything recorded before the move is still readable after it.
function showAnalytics(){ const d=analyticsLoad(); const by={}; const ses=new Set(); d.events.forEach(e=>{by[e.event]=(by[e.event]||0)+1;ses.add(e.session);}); function track(event,detail){ if(devMode) return; WPUsage.track(WPUsage.KEYS.creator, event, detail); }
let t=`USAGE ANALYTICS\n\nSessions: ${ses.size} Events: ${d.events.length}\n\nActions:\n`; Object.keys(by).forEach(k=>t+=` ${k}: ${by[k]}\n`); t+=`\nSaved packages (this device): ${savedPackages.length}\n\nDownload full log as JSON?`;
if(confirm(t)) downloadAnalytics(); }
function downloadAnalytics(){ const d=analyticsLoad(); const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='wp-iwp-usage-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('analytics_exported'); }
// ── COMMENTS ───────────────────────────────────────────────────────────────── // ── COMMENTS ─────────────────────────────────────────────────────────────────
const COMMENTS_KEY='wp_iwp_comments_v1'; const COMMENTS_KEY='wp_iwp_comments_v1';

View File

@@ -11,6 +11,7 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL <!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. --> during their own boot. -->
<script src="wp-url.js"></script> <script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). --> <!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script> <script src="wp-autosave.js"></script>
<!-- Which sections this project uses (CR-006). The same file the SOP wizard <!-- Which sections this project uses (CR-006). The same file the SOP wizard
@@ -89,7 +90,6 @@
<!-- T7.10 moves this to the admin console and deletes one of the two <!-- T7.10 moves this to the admin console and deletes one of the two
implementations. It stays visible here until then rather than being implementations. It stays visible here until then rather than being
deleted by the task that dissolved the frame. --> deleted by the task that dissolved the frame. -->
<button class="btn btn-ghost" onclick="showAnalytics()">Usage data</button>
</div> </div>
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div> <div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>

58
html/wp-usage.js Normal file
View File

@@ -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' },
};
})();

View File

@@ -235,10 +235,17 @@ def run(page, base, tok, db_path):
for needle in ("Localization defaults", "L10N_LOCALES", "L10N_ZONES", for needle in ("Localization defaults", "L10N_LOCALES", "L10N_ZONES",
"fillLocalization", "saveLocalization", "set-locale", "set-tz"): "fillLocalization", "saveLocalization", "set-locale", "set-tz"):
chk("admin.js still has %s" % needle, needle in src) chk("admin.js still has %s" % needle, needle in src)
changed = subprocess.run( # Re-pointed at T7.10, not relaxed. This asserted admin.js was byte-identical
["git", "diff", "--stat", "HEAD", "--", "html/admin.js"], # to HEAD - right for T6.5, whose task touched nothing there, but as a
cwd=ROOT, capture_output=True, text=True).stdout.strip() # standing probe it failed every LEGITIMATE later edit (D5 moved the usage
chk("...and this task changed nothing in it at all", not changed, changed) # 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(): def main():

View File

@@ -244,8 +244,13 @@ def page_checks(page, base, tok):
}); });
return JSON.stringify(out); 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("%-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("""(() => { chk("...and every one of them has a handler that exists", page.eval("""(() => {
return [...document.querySelectorAll('#wp-toolbar button')].every(b => { return [...document.querySelectorAll('#wp-toolbar button')].every(b => {
const m = (b.getAttribute('onclick') || '').match(/^([A-Za-z_$][\\w$]*)\\(/); const m = (b.getAttribute('onclick') || '').match(/^([A-Za-z_$][\\w$]*)\\(/);

192
tests/usage_check.py Normal file
View File

@@ -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"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. the grep half: one implementation, no leftover controls ───────────
print("\n1. grep: one implementation, nothing unreferenced")
files = {}
for name in os.listdir(HTML):
if name.endswith((".js", ".html")):
files[name] = strip_js(open(os.path.join(HTML, name), encoding="utf-8").read())
core_defs = [n for n, src in files.items() if "window.WPUsage" in src]
chk("the WPUsage core is defined in wp-usage.js and only there",
core_defs == ["wp-usage.js"], ascii_(core_defs))
chk("the pages record THROUGH it - no page touches the storage keys directly",
all("wp_iwp_analytics_v1" not in src and "wp_suite_analytics_v1" not in src
for n, src in files.items()
if n not in ("wp-usage.js",) and n.endswith(".js")))
leftovers = {n: re.findall(r"analyticsLoad|analyticsSave|downloadAnalytics|showAnalytics"
r"|ANALYTICS_KEY|USAGE_KEY|usageLoad|downloadUsage", src)
for n, src in files.items() if n != "wp-usage.js"}
leftovers = {n: v for n, v in leftovers.items() if v}
chk("none of the five colliding globals survives anywhere; grep confirms",
not leftovers, ascii_(leftovers))
chk("no analytics control remains on the creator or the wizard; grep confirms",
"Usage data" not in files["wp-creation-index.html"]
and "showAnalytics" not in files["work-package-suite.html"])
chk("both storage keys survive, verbatim, in the core (data continuity)",
"wp_iwp_analytics_v1" in files["wp-usage.js"]
and "wp_suite_analytics_v1" in files["wp-usage.js"])
tmpdir = tempfile.mkdtemp(prefix="wpsuite-usage-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
# ── 2. recording still works from both tools ─────────────────────────
print("\n2. the tools still record")
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
settle(2.0)
# Plant a LEGACY-format event under the pre-move key: the done-when is
# that data recorded before this task is still readable after it.
page.eval("""(() => {
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())