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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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').
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
58
html/wp-usage.js
Normal 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' },
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
192
tests/usage_check.py
Normal 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())
|
||||||
Reference in New Issue
Block a user