cyborg-hunter 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +78 -21
- package/package.json +10 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/ingest.js +623 -41
- package/src/cli/init.js +1 -1
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index.js +163 -24
- package/src/cli/renderers/replay-assets.js +177 -0
- package/src/cli/renderers/replay-viewer.client.js +1022 -0
- package/src/cli/renderers/session-timeline.js +917 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories.js +68 -7
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile.js +7 -1
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +60 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +32 -12
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/dist/cyborg-hunter.esm.js +0 -1527
- package/dist/cyborg-hunter.min.js +0 -6
- package/dist/extension-cyborg-hunter.js +0 -1
- package/dist/extension-guard-friction.js +0 -36
- package/dist/extension-guard-honeypot.js +0 -1
- package/src/cli/renderers/tab-timeline.js +0 -149
package/src/cli/init.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function runInit() {
|
|
|
16
16
|
|
|
17
17
|
// Minimal config — just the fields every project needs to set.
|
|
18
18
|
// filePattern defaults to JSON-or-CSV because jsPsych's `.csv()` save is
|
|
19
|
-
// the most common format we see in the wild (
|
|
19
|
+
// the most common format we see in the wild (Shape-2 producers use JSON; most
|
|
20
20
|
// jsPsych setups use CSV).
|
|
21
21
|
const config = {
|
|
22
22
|
dataDir: './data',
|
|
@@ -12,41 +12,40 @@ export async function renderEventLog(participants, config) {
|
|
|
12
12
|
const rows = [];
|
|
13
13
|
|
|
14
14
|
for (const p of participants) {
|
|
15
|
+
// Collect this participant's events with their session-absolute timestamp,
|
|
16
|
+
// then sort chronologically before serializing. All event timestamps are on
|
|
17
|
+
// the same performance.now() clock (copy/paste/drop/synthetic use `e.t`,
|
|
18
|
+
// tab-aways use `e.start`), so a single numeric sort interleaves them
|
|
19
|
+
// correctly. Without this the rows came out in loop/category order
|
|
20
|
+
// (all pastes, then all copies, …), contradicting the "chronological" docs.
|
|
21
|
+
const pEvents = [];
|
|
15
22
|
for (const trial of p.trials) {
|
|
16
23
|
const trialId = trial.trialId || trial.ruleId || '?';
|
|
17
24
|
|
|
18
|
-
// Paste events
|
|
19
25
|
for (const e of (trial.pasteEvents || [])) {
|
|
20
|
-
|
|
26
|
+
pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'paste', e.t, '', e.text) });
|
|
21
27
|
}
|
|
22
|
-
|
|
23
|
-
// Copy events
|
|
24
28
|
for (const e of (trial.copyEvents || [])) {
|
|
25
|
-
|
|
29
|
+
pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'copy', e.t, '', '') });
|
|
26
30
|
}
|
|
27
|
-
|
|
28
|
-
// Drop events
|
|
29
31
|
for (const e of (trial.dropEvents || [])) {
|
|
30
|
-
|
|
32
|
+
pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'drop', e.t, '', e.text) });
|
|
31
33
|
}
|
|
32
|
-
|
|
33
|
-
// Synthetic insertions (text appeared without keystrokes)
|
|
34
34
|
for (const e of (trial.syntheticInsertions || [])) {
|
|
35
|
-
|
|
35
|
+
pEvents.push({ ts: e.t, row: formatRow(p.participantId, trialId, 'synthetic', e.t, '', e.text) });
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// in this CSV share one chronological scale. The `text` column carries
|
|
41
|
-
// the tab-away type (windowBlur / visibilityChange / etc.) so analysts
|
|
42
|
-
// can filter by trigger.
|
|
37
|
+
// Tab-away timestamp uses the session-absolute `start` (the `text` column
|
|
38
|
+
// carries the trigger type — windowBlur / visibilityChange / etc. — so
|
|
39
|
+
// analysts can filter by trigger).
|
|
43
40
|
for (const e of (trial.tabAwayEvents || [])) {
|
|
44
|
-
|
|
41
|
+
pEvents.push({ ts: e.start, row: formatRow(p.participantId, trialId, 'tabAway', e.start, e.duration_ms, e.type || '') });
|
|
45
42
|
}
|
|
46
43
|
}
|
|
44
|
+
// Stable chronological sort; events with no usable timestamp sort last.
|
|
45
|
+
pEvents.sort((a, b) => (a.ts ?? Infinity) - (b.ts ?? Infinity));
|
|
46
|
+
for (const e of pEvents) rows.push(e.row);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// Sort chronologically within each participant
|
|
50
49
|
const csv = [header, ...rows].join('\n') + '\n';
|
|
51
50
|
const outPath = join(config.outputDir, 'event-log.csv');
|
|
52
51
|
writeFileSync(outPath, csv);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { writeFileSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
|
+
import { countSidebarOpenings } from '../analyzers/summary.js';
|
|
8
9
|
|
|
9
10
|
export async function renderExtensions(participants, config) {
|
|
10
11
|
const header = 'participantId,detectionType,name,details';
|
|
@@ -20,9 +21,17 @@ export async function renderExtensions(participants, config) {
|
|
|
20
21
|
rows.push(`${pid},extension,${escapeCSV(name)},`);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
// Sidebar detection
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Sidebar detection. Prefer the current library's session-level
|
|
25
|
+
// sidebarEvents (the monitor records sidebars session-scoped, not per-trial);
|
|
26
|
+
// fall back to the legacy per-trial sidebarGapPx only when no session events
|
|
27
|
+
// exist (pre-session / Shape-2 legacy data). countSidebarOpenings() counts
|
|
28
|
+
// distinct openings (collapsing the paired open/close records and the
|
|
29
|
+
// innerWidth_delta + layout_compression double-detection), matching the
|
|
30
|
+
// summary/triage count.
|
|
31
|
+
const sidebarOpens = countSidebarOpenings(p.session?.sidebarEvents);
|
|
32
|
+
if (sidebarOpens > 0) {
|
|
33
|
+
rows.push(`${pid},sidebar,browser_sidebar,${sidebarOpens} open event${sidebarOpens === 1 ? '' : 's'}`);
|
|
34
|
+
} else if (p.trials.some(t => (t.sidebarGapPx || 0) > 0)) {
|
|
26
35
|
const maxGap = Math.max(...p.trials.map(t => t.sidebarGapPx || 0));
|
|
27
36
|
rows.push(`${pid},sidebar,browser_sidebar,${maxGap}px gap`);
|
|
28
37
|
}
|
|
@@ -11,14 +11,22 @@
|
|
|
11
11
|
//
|
|
12
12
|
// References linked images in images/ (not base64-embedded). Works offline.
|
|
13
13
|
|
|
14
|
-
import { writeFileSync } from 'fs';
|
|
14
|
+
import { writeFileSync, readFileSync } from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
|
-
import { VERSION } from '../../shared/constants.js';
|
|
16
|
+
import { VERSION, sanitizeId } from '../../shared/constants.js';
|
|
17
17
|
import { decomposeScore } from '../analyzers/triage.js';
|
|
18
|
+
import { getByPath } from '../ingest.js';
|
|
19
|
+
|
|
20
|
+
// The replay viewer client is developed as a real JS file (linted, syntax-
|
|
21
|
+
// highlighted) and embedded verbatim at render time — same file://-safe
|
|
22
|
+
// output as the inline IIFE, without string-blob development pain.
|
|
23
|
+
const REPLAY_CLIENT_SRC = readFileSync(
|
|
24
|
+
new URL('./replay-viewer.client.js', import.meta.url), 'utf8');
|
|
18
25
|
|
|
19
26
|
export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
|
|
20
27
|
// Cohort counts for filter chips and totals footer. The triage array is
|
|
21
|
-
// already sorted
|
|
28
|
+
// already sorted tier-first (hard → soft → clean, score-desc within tier) by
|
|
29
|
+
// triage.js — we don't re-sort here; the default "Tier" sort matches it.
|
|
22
30
|
const hard = triage.filter(t => t.hardTriggered).length;
|
|
23
31
|
const soft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
|
|
24
32
|
const clean = triage.length - hard - soft;
|
|
@@ -33,7 +41,7 @@ export async function renderHtmlIndex(summaries, triage, participants, config, v
|
|
|
33
41
|
// exercised by the second smoke test.
|
|
34
42
|
const detailHtml = triage.map((t, i) => {
|
|
35
43
|
const participant = participants.find(p => p.participantId === t.participantId);
|
|
36
|
-
return renderDetail(t, participant, visualsRendered, /* defaultVisible */ i === 0);
|
|
44
|
+
return renderDetail(t, participant, config, visualsRendered, /* defaultVisible */ i === 0);
|
|
37
45
|
}).join('\n');
|
|
38
46
|
|
|
39
47
|
const html = `<!DOCTYPE html>
|
|
@@ -420,12 +428,12 @@ export async function renderHtmlIndex(summaries, triage, participants, config, v
|
|
|
420
428
|
<tr><td>copy</td> <td>Clipboard copy from the page (soft signal).</td></tr>
|
|
421
429
|
<tr><td>drop</td> <td>Drag-and-drop into the response field (hard signal).</td></tr>
|
|
422
430
|
<tr><td>tab-away ≥10s</td> <td>Left the page for ≥10 seconds; counts toward soft score.</td></tr>
|
|
423
|
-
<tr><td>tab-away
|
|
424
|
-
<tr><td>flicker
|
|
431
|
+
<tr><td>tab-away (mid)</td> <td>Longer than the participant's tab-away threshold (3s by default, 5s strict) and under 10s; counts toward soft score.</td></tr>
|
|
432
|
+
<tr><td>flicker</td> <td>At or below the tab-away threshold (3s by default); reported but not scored.</td></tr>
|
|
425
433
|
<tr><td>sidebar event</td> <td>Viewport width shrank suddenly — likely AI sidebar opened.</td></tr>
|
|
426
434
|
<tr><td>AI extension</td> <td>Browser extension known for AI assistance detected.</td></tr>
|
|
427
435
|
<tr><td>kb shortcut</td> <td>DevTools-adjacent keyboard shortcut used.</td></tr>
|
|
428
|
-
<tr><td>
|
|
436
|
+
<tr><td>viewport shift</td> <td>Viewport width changed substantially (recorded as "layout shift" before 0.6.1).</td></tr>
|
|
429
437
|
<tr><td>zoom change</td> <td>Browser zoom level changed.</td></tr>
|
|
430
438
|
<tr><td>synthetic insertion</td><td>Text appeared without matching keystrokes.</td></tr>
|
|
431
439
|
<tr><td>foreign input</td> <td>Keystroke outside the response field.</td></tr>
|
|
@@ -665,6 +673,79 @@ export async function renderHtmlIndex(summaries, triage, participants, config, v
|
|
|
665
673
|
});
|
|
666
674
|
})();
|
|
667
675
|
</script>
|
|
676
|
+
<style>
|
|
677
|
+
/* Replay viewer (see replay-viewer.client.js) — matches report house style */
|
|
678
|
+
.replay-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0; }
|
|
679
|
+
.replay-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px;
|
|
680
|
+
background: var(--ink); color: var(--surface); }
|
|
681
|
+
.replay-badge[data-tier="trace"] { background: var(--surface); color: var(--ink);
|
|
682
|
+
border: 1px solid var(--line); }
|
|
683
|
+
.replay-stage { position: relative; overflow: hidden; background: var(--surface);
|
|
684
|
+
border: 1px solid var(--line); border-radius: 4px; }
|
|
685
|
+
.replay-frame { position: absolute; top: 0; left: 0; border: 0; }
|
|
686
|
+
.replay-overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
|
|
687
|
+
.replay-neutral { background: #e8e6e0; }
|
|
688
|
+
.replay-neutral-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
|
689
|
+
color: var(--dim); font-size: 13px; }
|
|
690
|
+
.replay-lane { display: block; margin-top: 6px; border-radius: 2px; }
|
|
691
|
+
.replay-scrub { display: block; margin: 2px 0 4px; }
|
|
692
|
+
.replay-ticker { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
|
|
693
|
+
color: var(--dim); font-variant-numeric: tabular-nums;
|
|
694
|
+
height: 1.4em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
695
|
+
.replay-note { font-size: 12px; color: var(--dim); }
|
|
696
|
+
.replay-warn { color: var(--hard); }
|
|
697
|
+
/* Controls share the report's flat, line-bordered button style */
|
|
698
|
+
.replay-play, .replay-load-btn, .replay-css-btn, .replay-trial-select, .replay-speed {
|
|
699
|
+
padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
|
|
700
|
+
color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
|
|
701
|
+
.replay-play:hover, .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
|
|
702
|
+
.replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
|
|
703
|
+
.replay-css-btn { font-size: 12px; padding: 2px 8px; }
|
|
704
|
+
.replay-clock { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
|
|
705
|
+
font-variant-numeric: tabular-nums; }
|
|
706
|
+
</style>
|
|
707
|
+
<script>
|
|
708
|
+
${REPLAY_CLIENT_SRC}
|
|
709
|
+
</script>
|
|
710
|
+
<script>
|
|
711
|
+
// Lazy loader: replay models are heavy (dom tier ≈ MBs), so each
|
|
712
|
+
// participant's model script loads only when the analyst asks for it.
|
|
713
|
+
// Script-tag injection works under file:// where fetch() is blocked.
|
|
714
|
+
(function () {
|
|
715
|
+
document.addEventListener('click', function (e) {
|
|
716
|
+
const btn = e.target.closest('.replay-load-btn');
|
|
717
|
+
if (!btn) return;
|
|
718
|
+
const block = btn.closest('.replay-block');
|
|
719
|
+
const src = block.dataset.replaySrc;
|
|
720
|
+
const pid = block.dataset.pid;
|
|
721
|
+
const mount = block.querySelector('.replay-mount');
|
|
722
|
+
btn.disabled = true;
|
|
723
|
+
btn.textContent = 'Loading…';
|
|
724
|
+
mount.setAttribute('aria-busy', 'true');
|
|
725
|
+
const fail = function (msg) {
|
|
726
|
+
mount.removeAttribute('aria-busy');
|
|
727
|
+
const p = document.createElement('p');
|
|
728
|
+
p.className = 'replay-note replay-warn';
|
|
729
|
+
p.textContent = msg;
|
|
730
|
+
mount.textContent = '';
|
|
731
|
+
mount.appendChild(p);
|
|
732
|
+
};
|
|
733
|
+
const s = document.createElement('script');
|
|
734
|
+
s.src = src;
|
|
735
|
+
s.onload = function () {
|
|
736
|
+
mount.removeAttribute('aria-busy');
|
|
737
|
+
const model = (window.__chReplay || {})[pid];
|
|
738
|
+
if (!model) { fail('Replay data failed to load (' + src + ').'); return; }
|
|
739
|
+
mount.textContent = '';
|
|
740
|
+
window.initChReplayViewer(mount, model);
|
|
741
|
+
};
|
|
742
|
+
s.onerror = function () {
|
|
743
|
+
fail('Replay asset missing (' + src + ') — was the report generated with the replay artifacts present?');
|
|
744
|
+
};
|
|
745
|
+
document.body.appendChild(s);
|
|
746
|
+
});
|
|
747
|
+
})();
|
|
748
|
+
</script>
|
|
668
749
|
</body>
|
|
669
750
|
</html>`;
|
|
670
751
|
|
|
@@ -692,17 +773,17 @@ const SIGNALS = [
|
|
|
692
773
|
{ key: 'totalCopyEvents', label: 'Copy', tone: 'warn', hint: 'Clipboard copy events from the task' },
|
|
693
774
|
{ key: 'totalDropEvents', label: 'Drop', tone: 'critical', hint: 'Drag-and-drop into inputs' },
|
|
694
775
|
{ key: 'tabAwayLongCount', label: 'Tab-away ≥10s', tone: 'critical', hint: 'Window blurred for 10+ seconds' },
|
|
695
|
-
{ key: 'tabAwayMediumCount', label: 'Tab-away
|
|
696
|
-
{ key: 'tabAwayFlickerCount', label: 'Flicker
|
|
776
|
+
{ key: 'tabAwayMediumCount', label: 'Tab-away (mid)', tone: 'warn', hint: 'Above the tab-away threshold (3s default, 5s strict), under 10s' },
|
|
777
|
+
{ key: 'tabAwayFlickerCount', label: 'Flicker', tone: 'muted', hint: 'At or below the tab-away threshold (3s default) — not scored' },
|
|
697
778
|
{ key: 'trialsWithFastTyping', label: 'Fast typing', tone: 'warn', hint: 'Trials typed faster than the preset cps threshold' },
|
|
698
779
|
{ key: 'totalSyntheticInsertions', label: 'Synthetic', tone: 'warn', hint: 'Text appeared without preceding keystrokes' },
|
|
699
780
|
{ key: 'totalForeignInputEvents', label: 'Foreign input', tone: 'warn', hint: 'Typing landed outside the experiment container' },
|
|
700
781
|
{ key: 'sidebarEventCount', label: 'Sidebar', tone: 'warn', hint: 'innerWidth shrank — side panel opened' },
|
|
701
782
|
{ key: 'aiExtensionsCount', label: 'AI extensions', tone: 'critical', hint: 'Known AI extension selectors found in DOM' },
|
|
702
|
-
{ key: 'keyboardShortcutCount', label: 'Kb shortcuts', tone: 'warn', hint: 'Cmd/
|
|
703
|
-
{ key: 'layoutShiftCount', label: '
|
|
783
|
+
{ key: 'keyboardShortcutCount', label: 'Kb shortcuts', tone: 'warn', hint: 'DevTools hotkeys: Ctrl/Cmd+Shift+I/J/C, F12' },
|
|
784
|
+
{ key: 'layoutShiftCount', label: 'Viewport shifts', tone: 'muted', hint: 'Viewport-width change events (recorded as layoutShifts before 0.6.1)' },
|
|
704
785
|
{ key: 'zoomChangeCount', label: 'Zoom changes', tone: 'muted', hint: 'Browser zoom level changed during task' },
|
|
705
|
-
{ key: 'devToolsEventCount', label: 'DevTools', tone: 'muted', hint: 'DevTools
|
|
786
|
+
{ key: 'devToolsEventCount', label: 'DevTools', tone: 'muted', hint: 'Reserved (always 0) — DevTools opens are counted under Kb shortcuts' },
|
|
706
787
|
{ key: 'edgeExitCount', label: 'Edge exits', tone: 'muted', hint: 'Mouse exited window through a screen edge' }
|
|
707
788
|
];
|
|
708
789
|
|
|
@@ -753,9 +834,9 @@ function renderCohortList(triage, cohortCounts) {
|
|
|
753
834
|
<div class="sort-wrap">
|
|
754
835
|
<label>Sort:
|
|
755
836
|
<select name="sort">
|
|
837
|
+
<option value="tier" selected>Tier (hard first)</option>
|
|
756
838
|
<option value="score">Score ↓</option>
|
|
757
839
|
<option value="id">ID</option>
|
|
758
|
-
<option value="tier">Tier</option>
|
|
759
840
|
</select>
|
|
760
841
|
</label>
|
|
761
842
|
</div>
|
|
@@ -824,13 +905,14 @@ function sanitize(name) {
|
|
|
824
905
|
// signals, paste evidence, and images. The `hidden` attribute is omitted on the
|
|
825
906
|
// first pane so the report has a default selection on load; client JS toggles
|
|
826
907
|
// `hidden` on the others when the user clicks a different cohort row.
|
|
827
|
-
function renderDetail(t, participant, visualsRendered, defaultVisible) {
|
|
908
|
+
function renderDetail(t, participant, config, visualsRendered, defaultVisible) {
|
|
828
909
|
const sanitized = sanitize(t.participantId);
|
|
829
910
|
const tier = tierOf(t);
|
|
830
911
|
const s = t.summary || {};
|
|
831
912
|
|
|
832
|
-
// Image sections — three .image-block wrappers (
|
|
833
|
-
// mouse trajectories). The renderers in
|
|
913
|
+
// Image sections — three .image-block wrappers (session timeline, typing
|
|
914
|
+
// profile, mouse trajectories). The renderers in session-timeline.js /
|
|
915
|
+
// typing-profile.js /
|
|
834
916
|
// trajectories.js skip participants under various conditions, so not every
|
|
835
917
|
// participant has every image. The wrapper exists so onerror can hide both
|
|
836
918
|
// the heading and the image together — without it, a missing PNG would leave
|
|
@@ -849,9 +931,9 @@ function renderDetail(t, participant, visualsRendered, defaultVisible) {
|
|
|
849
931
|
</a>
|
|
850
932
|
</div>
|
|
851
933
|
<div class="image-block">
|
|
852
|
-
<h4 class="section-heading">
|
|
853
|
-
<a href="images/
|
|
854
|
-
<img src="images/
|
|
934
|
+
<h4 class="section-heading">Session timeline</h4>
|
|
935
|
+
<a href="images/session_timeline_${sanitized}.png" class="zoomable">
|
|
936
|
+
<img src="images/session_timeline_${sanitized}.png" alt="Session timeline"
|
|
855
937
|
onerror="this.closest('.image-block').style.display='none'">
|
|
856
938
|
</a>
|
|
857
939
|
</div>
|
|
@@ -867,16 +949,59 @@ function renderDetail(t, participant, visualsRendered, defaultVisible) {
|
|
|
867
949
|
}
|
|
868
950
|
|
|
869
951
|
return `<section class="participant" id="p-${sanitized}"${defaultVisible ? '' : ' hidden'}>
|
|
870
|
-
${renderDetailHeader(t, tier)}
|
|
952
|
+
${renderDetailHeader(t, tier, participant, config)}
|
|
871
953
|
${renderSignalGrid(s, t)}
|
|
872
954
|
${renderScoreBreakdown(t)}
|
|
873
955
|
${renderReasonQuote(t)}
|
|
874
956
|
${renderSessionBlock(s, participant)}
|
|
875
957
|
${renderPasteEvidence(participant)}
|
|
876
958
|
${imagesHtml}
|
|
959
|
+
${renderReplaySection(participant, sanitized)}
|
|
877
960
|
</section>`;
|
|
878
961
|
}
|
|
879
962
|
|
|
963
|
+
// Renders the per-participant "Session replay" section in one of three
|
|
964
|
+
// states: loadable (artifact attached), corrupted, or absent (with the
|
|
965
|
+
// saved_to reason from integrityReplayMeta when one exists, so the analyst
|
|
966
|
+
// can tell "never recorded" from "went to the participant's Downloads").
|
|
967
|
+
function renderReplaySection(participant, sanitized) {
|
|
968
|
+
const replay = participant?.replay;
|
|
969
|
+
if (replay && replay.recording) {
|
|
970
|
+
const tier = replay.recording.metadata?.tier || 'trace';
|
|
971
|
+
// assetPath is stamped by replay-assets.js (collision-deduped filename)
|
|
972
|
+
// and must be preferred — recomputing from the sanitized pid here would
|
|
973
|
+
// resurrect the lossy-name collision the assets renderer just resolved.
|
|
974
|
+
// Fallback uses the SHARED sanitizer (persistence/ingest/assets),
|
|
975
|
+
// not this file's image-oriented sanitize (which truncates + strips
|
|
976
|
+
// dots and would miss the asset filename for long/dotted pids).
|
|
977
|
+
const assetPath = replay.assetPath || `replay/${sanitizeId(participant.participantId)}.replay.js`;
|
|
978
|
+
return `<div class="image-block replay-block" data-pid="${esc(participant.participantId)}"
|
|
979
|
+
data-replay-src="${esc(assetPath)}">
|
|
980
|
+
<h4 class="section-heading">Session replay <span class="replay-note">(${esc(tier)} tier)</span></h4>
|
|
981
|
+
<div class="replay-mount">
|
|
982
|
+
<button class="replay-load-btn" type="button">Load replay</button>
|
|
983
|
+
</div>
|
|
984
|
+
</div>`;
|
|
985
|
+
}
|
|
986
|
+
if (replay && replay.error) {
|
|
987
|
+
return `<div class="image-block replay-block">
|
|
988
|
+
<h4 class="section-heading">Session replay</h4>
|
|
989
|
+
<p class="replay-note replay-warn">Replay artifact corrupted (${esc(replay.reason || replay.error)}) — file: ${esc(replay.file || 'unknown')}</p>
|
|
990
|
+
</div>`;
|
|
991
|
+
}
|
|
992
|
+
const meta = participant?.trials?.[0]?.integrityReplayMeta;
|
|
993
|
+
if (meta) {
|
|
994
|
+
return `<div class="image-block replay-block">
|
|
995
|
+
<h4 class="section-heading">Session replay</h4>
|
|
996
|
+
<p class="replay-note">No replay artifact on disk. The session reported saved_to: <strong>${esc(String(meta.saved_to))}</strong>${meta.saved_to === 'download' ? ' — the file went to the participant’s machine and is not recoverable' : ''}.</p>
|
|
997
|
+
</div>`;
|
|
998
|
+
}
|
|
999
|
+
return `<div class="image-block replay-block">
|
|
1000
|
+
<h4 class="section-heading">Session replay</h4>
|
|
1001
|
+
<p class="replay-note">No replay artifact (recording was not enabled for this session).</p>
|
|
1002
|
+
</div>`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
880
1005
|
// Renders the participant's screenout reason as a left-bordered pull-quote.
|
|
881
1006
|
// `t.reason` is set by triage.js; falls back to "clean" when absent. Always
|
|
882
1007
|
// renders a quote (even for clean participants) so the visual rhythm is consistent.
|
|
@@ -955,12 +1080,26 @@ function formatSidebar(ev) {
|
|
|
955
1080
|
}
|
|
956
1081
|
|
|
957
1082
|
// Header strip: full participant id (mono), tier pill, big score number on the
|
|
958
|
-
// top row; "{n} trials · {cps} cps · {off-task}" subline below.
|
|
959
|
-
|
|
1083
|
+
// top row; "{n} trials · {cps} cps · {off-task}" subline below. When BOTH
|
|
1084
|
+
// config.platformIdField is mapped AND config.showPlatformId is true, a
|
|
1085
|
+
// second subline shows the platform (Prolific/MTurk) ID — OFF by default
|
|
1086
|
+
// because reports circulate among collaborators more freely than raw data,
|
|
1087
|
+
// and the platform ID is the piece that re-identifies a participant.
|
|
1088
|
+
function renderDetailHeader(t, tier, participant, config) {
|
|
960
1089
|
const s = t.summary || {};
|
|
961
1090
|
const cps = (s.meanTypingSpeed || 0).toFixed(1);
|
|
962
1091
|
const offTask = formatDuration(s.totalTabAwayDuration_ms || 0);
|
|
963
1092
|
const tierLabel = tier === 'clean' ? 'clean' : tier.toUpperCase();
|
|
1093
|
+
let platformLine = '';
|
|
1094
|
+
if (config?.showPlatformId && config?.platformIdField) {
|
|
1095
|
+
const value = getByPath(participant?.metadata, config.platformIdField)
|
|
1096
|
+
?? getByPath(participant, config.platformIdField)
|
|
1097
|
+
?? getByPath(participant?.trials?.[0], config.platformIdField);
|
|
1098
|
+
if (value != null && value !== '') {
|
|
1099
|
+
platformLine = `
|
|
1100
|
+
<div class="detail-header-sub mono">platform ID: ${esc(String(value))}</div>`;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
964
1103
|
return `<div class="detail-header">
|
|
965
1104
|
<div class="detail-header-top">
|
|
966
1105
|
<span class="mono pid-full">${esc(t.participantId)}</span>
|
|
@@ -969,7 +1108,7 @@ function renderDetailHeader(t, tier) {
|
|
|
969
1108
|
</div>
|
|
970
1109
|
<div class="detail-header-sub">
|
|
971
1110
|
${s.trialCount ?? 0} trials · ${cps} cps · ${offTask} off-task
|
|
972
|
-
</div
|
|
1111
|
+
</div>${platformLine}
|
|
973
1112
|
</div>`;
|
|
974
1113
|
}
|
|
975
1114
|
|
|
@@ -1012,7 +1151,7 @@ function formatDuration(ms) {
|
|
|
1012
1151
|
// Task 11's client JS will wire up to swap the preview for the full text.
|
|
1013
1152
|
function renderPasteEvidence(participant) {
|
|
1014
1153
|
// Flatten across trials, keeping [trialId] context for each paste. Match the
|
|
1015
|
-
// event-log.csv convention: prefer trialId, fall back to ruleId (
|
|
1154
|
+
// event-log.csv convention: prefer trialId, fall back to ruleId (Shape-2
|
|
1016
1155
|
// legacy data), then to '?' as a last resort. Paste text is coerced to string
|
|
1017
1156
|
// because some malformed sources have non-string values.
|
|
1018
1157
|
const pastes = [];
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/cli/renderers/replay-assets.js
|
|
2
|
+
// Emits per-participant replay assets into <outputDir>/replay/ as
|
|
3
|
+
// JSONP-style scripts: window.__chReplay['<pid>'] = <viewer model>.
|
|
4
|
+
//
|
|
5
|
+
// Why script files instead of JSON + fetch(): the HTML report is opened
|
|
6
|
+
// via file:// where fetch() of local files is CORS-blocked, but <script>
|
|
7
|
+
// tags load fine. The report injects the script lazily when the analyst
|
|
8
|
+
// opens a participant's Replay section.
|
|
9
|
+
//
|
|
10
|
+
// This module is also the wire→viewer time conversion (the second of the
|
|
11
|
+
// two allowed conversion points): SessionRecording carries ms since
|
|
12
|
+
// session start; the viewer speaks trial-relative ms.
|
|
13
|
+
|
|
14
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
|
|
17
|
+
import { sanitizeId as sanitize } from '../../shared/constants.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wire SessionRecording → viewer model. Times become trial-relative
|
|
21
|
+
* (t − t_load); null anchors (standalone implicit trials) degrade to the
|
|
22
|
+
* first event's time so durations are always finite.
|
|
23
|
+
*/
|
|
24
|
+
export function buildViewerModel(recording) {
|
|
25
|
+
const md = recording.metadata || {};
|
|
26
|
+
const ext = recording.ch_extensions || {};
|
|
27
|
+
|
|
28
|
+
// ── Camera seeding (central, per Sol round-1 finding 8) ──
|
|
29
|
+
// New recordings carry a per-trial view_state seed. Legacy recordings
|
|
30
|
+
// don't — but the full event stream is present, so each trial's starting
|
|
31
|
+
// camera is reconstructed by folding all PRIOR trials' window-scroll and
|
|
32
|
+
// resize events over the session-start viewport. Initial scroll is assumed
|
|
33
|
+
// 0 (a recording that starts pre-scrolled with no scroll events is
|
|
34
|
+
// unrecoverable — that's what the viewer's legacy banner covers).
|
|
35
|
+
const vp = recording.viewport || {};
|
|
36
|
+
const vv = vp.visual_viewport || {};
|
|
37
|
+
// Scrollbar delta: legacy resize events carry only innerWidth/Height; the
|
|
38
|
+
// layout (client) width is estimated as w minus the session-start delta
|
|
39
|
+
// between innerWidth and the layout width (visual_viewport.width).
|
|
40
|
+
const sbW = (vp.width && (vp.client_width || vv.width))
|
|
41
|
+
? vp.width - (vp.client_width || vv.width) : 0;
|
|
42
|
+
const sbH = (vp.height && (vp.client_height || vv.height))
|
|
43
|
+
? vp.height - (vp.client_height || vv.height) : 0;
|
|
44
|
+
let foldState = {
|
|
45
|
+
x: 0, y: 0,
|
|
46
|
+
w: vp.width || null, h: vp.height || null,
|
|
47
|
+
cw: vp.client_width || vv.width || vp.width || null,
|
|
48
|
+
ch: vp.client_height || vv.height || vp.height || null,
|
|
49
|
+
dpr: vp.dpr || 1
|
|
50
|
+
};
|
|
51
|
+
const foldEvent = (state, e) => {
|
|
52
|
+
if (e.kind === 'scroll' && e.el == null && e.redacted == null) {
|
|
53
|
+
state.x = Number(e.x) || 0;
|
|
54
|
+
state.y = Number(e.y) || 0;
|
|
55
|
+
} else if (e.kind === 'resize') {
|
|
56
|
+
state.w = e.w != null ? e.w : state.w;
|
|
57
|
+
state.h = e.h != null ? e.h : state.h;
|
|
58
|
+
state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
|
|
59
|
+
state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
|
|
60
|
+
if (e.dpr != null) state.dpr = e.dpr;
|
|
61
|
+
}
|
|
62
|
+
return state;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Defensive against malformed/truncated artifacts (a hand-edited or
|
|
66
|
+
// partially-written recording): non-array trials/events and null entries must
|
|
67
|
+
// degrade to empty rather than throw and abort the whole cohort report.
|
|
68
|
+
const rawTrials = Array.isArray(recording.trials) ? recording.trials : [];
|
|
69
|
+
const trials = rawTrials.map((trial) => {
|
|
70
|
+
trial = trial || {};
|
|
71
|
+
// Sort by absolute time before anchoring. RAF-coalesced input events flush
|
|
72
|
+
// with an EARLIER timestamp than events pushed after they were enqueued, so
|
|
73
|
+
// the recorded array is not strictly time-ordered; the viewer scrubs by
|
|
74
|
+
// scanning until the first future event and would otherwise mis-apply an
|
|
75
|
+
// out-of-order event on a seek. Stable sort keeps equal-time order.
|
|
76
|
+
const events = (Array.isArray(trial.events) ? trial.events : [])
|
|
77
|
+
.filter((e) => e && typeof e === 'object')
|
|
78
|
+
.slice()
|
|
79
|
+
.sort((a, b) => (Number(a.t) || 0) - (Number(b.t) || 0));
|
|
80
|
+
const anchor = trial.t_load != null ? trial.t_load
|
|
81
|
+
: (events.length > 0 ? events[0].t : 0);
|
|
82
|
+
const lastT = events.length > 0 ? events[events.length - 1].t : anchor;
|
|
83
|
+
const end = trial.t_end != null ? trial.t_end : lastT;
|
|
84
|
+
// Camera seed for THIS trial: recorded view_state, else the folded state
|
|
85
|
+
// as of the end of the previous trial. A recorded view_state also
|
|
86
|
+
// RESYNCS the fold — in a mixed recording (some trials seeded, some
|
|
87
|
+
// not: truncation, version mixes) a later unseeded trial must inherit
|
|
88
|
+
// real observed state, not a fold that ignored every observation.
|
|
89
|
+
if (trial.view_state) foldState = Object.assign({}, foldState, trial.view_state);
|
|
90
|
+
const camera = trial.view_state
|
|
91
|
+
? Object.assign({}, trial.view_state, { source: 'view_state' })
|
|
92
|
+
: Object.assign({}, foldState, { source: 'folded' });
|
|
93
|
+
// Advance the fold across this trial's events for the NEXT trial's seed.
|
|
94
|
+
events.forEach((e) => foldEvent(foldState, e));
|
|
95
|
+
return {
|
|
96
|
+
index: trial.trial_index,
|
|
97
|
+
id: trial.trial_id,
|
|
98
|
+
plugin: trial.plugin,
|
|
99
|
+
durMs: Math.max(0, Math.round((end - anchor) * 10) / 10),
|
|
100
|
+
initialDom: trial.initial_dom || '',
|
|
101
|
+
camera,
|
|
102
|
+
events: events.map((e) => {
|
|
103
|
+
const out = Object.assign({}, e);
|
|
104
|
+
out.t = Math.max(0, Math.round(((Number(e.t) || 0) - anchor) * 10) / 10);
|
|
105
|
+
return out;
|
|
106
|
+
})
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
pid: md.participant_id != null ? String(md.participant_id) : 'unknown',
|
|
112
|
+
tier: md.tier || 'trace',
|
|
113
|
+
keys: md.keys || null,
|
|
114
|
+
startTime: md.start_time || null,
|
|
115
|
+
endReason: md.end_reason || null,
|
|
116
|
+
recorder: md.recorder || null,
|
|
117
|
+
viewport: recording.viewport || null,
|
|
118
|
+
// Legacy = ANY trial lacks a view_state seed (all-legacy recordings and
|
|
119
|
+
// mixed/truncated ones alike): those trials replay on folded camera
|
|
120
|
+
// state, so the reduced-guarantees banner must show.
|
|
121
|
+
legacy: !rawTrials.every((t) => t && t.view_state),
|
|
122
|
+
markerAttr: ext.marker_attr || null,
|
|
123
|
+
// Session scrollbar delta (innerWidth − layout width): the viewer uses
|
|
124
|
+
// the same fallback chain as the folding above for legacy resize events.
|
|
125
|
+
scrollbar: { w: sbW, h: sbH },
|
|
126
|
+
stylesheets: (recording.stylesheets && recording.stylesheets.initial) || [],
|
|
127
|
+
scoring: ext.scoring || null,
|
|
128
|
+
guardViolations: ext.guard_violations || [],
|
|
129
|
+
captureStopped: !!ext.capture_stopped,
|
|
130
|
+
captureFailures: (ext.capture_failures || []).map((f) => f.channel),
|
|
131
|
+
trials
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Writes replay/<sanitizedPid>.replay.js for every participant with an
|
|
137
|
+
* attached recording. Returns { count, totalBytes } so report.js can print
|
|
138
|
+
* an honest size line (replay assets dominate report size at dom tier).
|
|
139
|
+
*/
|
|
140
|
+
export function renderReplayAssets(participants, outputDir) {
|
|
141
|
+
let count = 0;
|
|
142
|
+
let totalBytes = 0;
|
|
143
|
+
const withReplay = participants.filter((p) => p.replay && p.replay.recording);
|
|
144
|
+
if (withReplay.length === 0) return { count, totalBytes };
|
|
145
|
+
|
|
146
|
+
const replayDir = join(outputDir, 'replay');
|
|
147
|
+
mkdirSync(replayDir, { recursive: true });
|
|
148
|
+
|
|
149
|
+
// Sanitization is lossy ('a/b' and 'a_b' both map to a_b) — dedupe with a
|
|
150
|
+
// stable numeric suffix so a later write can never overwrite an earlier
|
|
151
|
+
// participant's asset. The actual path is stamped on the participant
|
|
152
|
+
// (replay.assetPath) and consumed by html-index, which must not recompute.
|
|
153
|
+
const usedNames = new Set();
|
|
154
|
+
for (const p of withReplay) {
|
|
155
|
+
const model = buildViewerModel(p.replay.recording);
|
|
156
|
+
// The store is keyed by the RAW participant id (what the report's
|
|
157
|
+
// loader passes); the filename uses the sanitized form.
|
|
158
|
+
// Null-prototype store: participant ids are untrusted, and a pid like
|
|
159
|
+
// "__proto__" on a plain object would mutate the store's prototype
|
|
160
|
+
// instead of creating an entry (prototype pollution).
|
|
161
|
+
const src =
|
|
162
|
+
'window.__chReplay = window.__chReplay || Object.create(null);\n' +
|
|
163
|
+
'window.__chReplay[' + JSON.stringify(String(p.participantId)) + '] = ' +
|
|
164
|
+
JSON.stringify(model).replace(/</g, '\\u003c') + ';\n';
|
|
165
|
+
let base = sanitize(p.participantId);
|
|
166
|
+
let name = base + '.replay.js';
|
|
167
|
+
for (let n = 2; usedNames.has(name.toLowerCase()); n++) {
|
|
168
|
+
name = base + '~' + n + '.replay.js';
|
|
169
|
+
}
|
|
170
|
+
usedNames.add(name.toLowerCase());
|
|
171
|
+
writeFileSync(join(replayDir, name), src);
|
|
172
|
+
p.replay.assetPath = 'replay/' + name;
|
|
173
|
+
count++;
|
|
174
|
+
totalBytes += Buffer.byteLength(src);
|
|
175
|
+
}
|
|
176
|
+
return { count, totalBytes };
|
|
177
|
+
}
|