cyborg-hunter 0.7.5 → 0.9.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 +74 -0
- package/CITATION.cff +2 -2
- package/README.md +22 -7
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +5 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/package.json +10 -2
- package/src/cli/analyzers/score-weights.js +145 -0
- package/src/cli/analyzers/triage.js +55 -39
- package/src/cli/config.js +15 -0
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +88 -22
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/renderers/score-weights.js +16 -0
- package/src/cli/renderers/summary-csv.js +2 -0
- package/src/cli/renderers/trajectories-core.js +2 -1
- package/src/cli/renderers/triage-md.js +9 -2
- package/src/cli/report.js +16 -3
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- package/src/shared/schema.js +1 -0
- package/src/shared/validation.js +1 -1
- package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
import { VERSION, sanitizeId } from '../../shared/constants.js';
|
|
23
23
|
import { decomposeScore } from '../analyzers/triage.js';
|
|
24
|
+
import { resolveScoreWeights, customWeightsText, formatScore } from '../analyzers/score-weights.js';
|
|
24
25
|
import { getByPath } from '../../shared/paths.js';
|
|
26
|
+
import { inferTier } from '../../replay/viewer-model.js';
|
|
27
|
+
import { inlineSafeJson, inlineSafeSrc } from '../../shared/inline-safe.js';
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Renders the report's index.html as a string. `opts.replayClientSrc` is the
|
|
@@ -33,7 +36,14 @@ import { getByPath } from '../../shared/paths.js';
|
|
|
33
36
|
* pre-split renderHtmlIndex always rendered).
|
|
34
37
|
*/
|
|
35
38
|
export async function renderIndexHtml(summaries, triage, participants, config, visualsRendered, opts = {}) {
|
|
36
|
-
|
|
39
|
+
// Inlining JS into an HTML <script> means owning the one sequence the HTML
|
|
40
|
+
// parser reacts to (see src/shared/inline-safe.js for both rules and the
|
|
41
|
+
// precondition the source rule carries). Without it, one comment discussing
|
|
42
|
+
// script-end-tag breakouts — two of the v2 replay modules have one —
|
|
43
|
+
// truncates the whole viewer and the report boots with a SyntaxError. This
|
|
44
|
+
// was hand-rolled in nine places and missing from exactly this one, which is
|
|
45
|
+
// why the rule now lives in one module (T5 Task 10 + its fix round 3).
|
|
46
|
+
const replayClientSrc = inlineSafeSrc(opts.replayClientSrc);
|
|
37
47
|
const visualsUnavailableNote = opts.visualsUnavailableNote
|
|
38
48
|
?? 'Visual renderers not available (install the canvas package).';
|
|
39
49
|
const imageSources = opts.imageSources ?? null; // pid → {typingProfile, sessionTimeline, trajectories} data URIs (null entry = omit that img)
|
|
@@ -52,11 +62,11 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
|
|
|
52
62
|
|
|
53
63
|
// Preloaded replay models (demo mode only) — embedded ahead of the replay
|
|
54
64
|
// client script so window.__chReplay exists before any viewer code runs.
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
65
|
+
// This is DATA, so it takes the every-`<` rule (inline-safe.js rule 1),
|
|
66
|
+
// which is both lossless inside a JSON string and complete: no `<` survives
|
|
67
|
+
// for the HTML tokenizer to react to, in any of its states.
|
|
58
68
|
const preloadedReplayScript = inlineReplayModels
|
|
59
|
-
? `<script>/* preloaded replay models (demo mode) */window.__chReplay = ${
|
|
69
|
+
? `<script>/* preloaded replay models (demo mode) */window.__chReplay = ${inlineSafeJson(inlineReplayModels)};</script>\n `
|
|
60
70
|
: '';
|
|
61
71
|
|
|
62
72
|
// Hash-sync emission for the rail click handler (selectById, below): demo
|
|
@@ -450,7 +460,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
|
|
|
450
460
|
<body data-filter="all">
|
|
451
461
|
<header class="topbar">
|
|
452
462
|
<h1>Cyborg Hunter Report</h1>
|
|
453
|
-
<span class="meta">${triage.length} participants · v${VERSION}</span>
|
|
463
|
+
<span class="meta">${triage.length} participants · v${VERSION}${scoreWeightsNote(config)}</span>
|
|
454
464
|
<button class="legend-btn" type="button" aria-haspopup="dialog" aria-controls="legend-modal">Legend ⓘ</button>
|
|
455
465
|
</header>
|
|
456
466
|
<div class="layout">
|
|
@@ -740,11 +750,13 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
|
|
|
740
750
|
.replay-note { font-size: 12px; color: var(--dim); }
|
|
741
751
|
.replay-warn { color: var(--hard); }
|
|
742
752
|
/* Controls share the report's flat, line-bordered button style */
|
|
743
|
-
.replay-play, .replay-load-btn, .replay-css-btn, .replay-
|
|
753
|
+
.replay-play, .replay-load-btn, .replay-css-btn, .replay-segment-select, .replay-speed {
|
|
744
754
|
padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
|
|
745
755
|
color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
|
|
746
756
|
.replay-play:hover, .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
|
|
747
757
|
.replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
|
|
758
|
+
.replay-fetch-css-label { margin-left: 10px; font-size: 12px; color: #555; }
|
|
759
|
+
.replay-unstyled { position: absolute; left: 0; right: 0; top: 0; z-index: 3; background: #fff4dd; color: #7a4b00; border-bottom: 1px solid #e8b24a; padding: 6px 10px; font-size: 12px; line-height: 1.4; }
|
|
748
760
|
.replay-css-btn { font-size: 12px; padding: 2px 8px; }
|
|
749
761
|
.replay-clock { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
|
|
750
762
|
font-variant-numeric: tabular-nums; }
|
|
@@ -754,6 +766,13 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
|
|
|
754
766
|
background: rgba(0,0,0,0.72); color: #fff; padding: 2px 7px;
|
|
755
767
|
border-radius: 3px; white-space: nowrap; }
|
|
756
768
|
.replay-key-chip--redacted { background: rgba(0,0,0,0.5); font-style: italic; }
|
|
769
|
+
/* Media is reported as state, never played (design §7): the badges say what
|
|
770
|
+
the recorded element was doing, in the stage's top-left. */
|
|
771
|
+
.replay-media { position: absolute; left: 0; top: 0; display: flex; gap: 4px;
|
|
772
|
+
padding: 5px 6px; pointer-events: none; flex-wrap: wrap; }
|
|
773
|
+
.replay-media-badge { font: 11px/1.2 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
|
|
774
|
+
background: rgba(0,137,123,0.85); color: #fff; padding: 2px 7px;
|
|
775
|
+
border-radius: 3px; white-space: nowrap; }
|
|
757
776
|
</style>
|
|
758
777
|
${preloadedReplayScript}<script>
|
|
759
778
|
${replayClientSrc}
|
|
@@ -770,6 +789,9 @@ ${replayClientSrc}
|
|
|
770
789
|
const src = block.dataset.replaySrc;
|
|
771
790
|
const pid = block.dataset.pid;
|
|
772
791
|
const mount = block.querySelector('.replay-mount');
|
|
792
|
+
// Read the fetch decision before the mount is cleared.
|
|
793
|
+
const fetchBox = block.querySelector('.replay-fetch-css');
|
|
794
|
+
const viewerOpts = { externalCss: !!(fetchBox && fetchBox.checked) };
|
|
773
795
|
btn.disabled = true;
|
|
774
796
|
btn.textContent = 'Loading…';
|
|
775
797
|
mount.setAttribute('aria-busy', 'true');
|
|
@@ -788,7 +810,7 @@ ${replayClientSrc}
|
|
|
788
810
|
if (preloaded) {
|
|
789
811
|
mount.removeAttribute('aria-busy');
|
|
790
812
|
mount.textContent = '';
|
|
791
|
-
window.initChReplayViewer(mount, preloaded);
|
|
813
|
+
window.initChReplayViewer(mount, preloaded, viewerOpts);
|
|
792
814
|
return;
|
|
793
815
|
}
|
|
794
816
|
` : ''}const s = document.createElement('script');
|
|
@@ -798,7 +820,7 @@ ${replayClientSrc}
|
|
|
798
820
|
const model = (window.__chReplay || {})[pid];
|
|
799
821
|
if (!model) { fail('Replay data failed to load (' + src + ').'); return; }
|
|
800
822
|
mount.textContent = '';
|
|
801
|
-
window.initChReplayViewer(mount, model);
|
|
823
|
+
window.initChReplayViewer(mount, model, viewerOpts);
|
|
802
824
|
};
|
|
803
825
|
s.onerror = function () {
|
|
804
826
|
fail('Replay asset missing (' + src + ') — was the report generated with the replay artifacts present?');
|
|
@@ -825,7 +847,8 @@ function tierOf(t) {
|
|
|
825
847
|
// drop, long tab-aways, AI extensions detected).
|
|
826
848
|
// warn — soft-score-contributing signals that compound with others.
|
|
827
849
|
// muted — diagnostic signals that are noise alone but corroborate.
|
|
828
|
-
// Tone classes track
|
|
850
|
+
// Tone classes track the DEFAULT score weights (score-weights.js); custom
|
|
851
|
+
// config.scoreWeights do not retone the tiles. If the defaults change, audit this.
|
|
829
852
|
// `aiExtensionsCount` and `edgeExitCount` are derived (not on summary directly);
|
|
830
853
|
// renderSignalGrid handles that mapping.
|
|
831
854
|
const SIGNALS = [
|
|
@@ -939,7 +962,7 @@ function renderCohortRow(t) {
|
|
|
939
962
|
<div class="cohort-row-top">
|
|
940
963
|
<span class="tier-dot" data-tier="${tier}"></span>
|
|
941
964
|
<span class="mono pid" title="${esc(pid)}">${esc(pid)}</span>
|
|
942
|
-
<span class="mono score">${score}</span>
|
|
965
|
+
<span class="mono score">${formatScore(score)}</span>
|
|
943
966
|
</div>
|
|
944
967
|
<div class="cohort-row-bot">
|
|
945
968
|
<span class="reason-excerpt">${esc(reason)}</span>
|
|
@@ -1013,7 +1036,7 @@ function renderDetail(t, participant, config, visualsRendered, visualsUnavailabl
|
|
|
1013
1036
|
return `<section class="participant" id="p-${sanitized}"${defaultVisible ? '' : ' hidden'}>
|
|
1014
1037
|
${renderDetailHeader(t, tier, participant, config)}
|
|
1015
1038
|
${renderSignalGrid(s, t)}
|
|
1016
|
-
${renderScoreBreakdown(t)}
|
|
1039
|
+
${renderScoreBreakdown(t, config)}
|
|
1017
1040
|
${renderReasonQuote(t)}
|
|
1018
1041
|
${renderSessionBlock(s, participant)}
|
|
1019
1042
|
${renderPasteEvidence(participant)}
|
|
@@ -1041,9 +1064,20 @@ function renderReplaySection(participant, sanitized, demoModel = null, replaySho
|
|
|
1041
1064
|
if (!participant) return '';
|
|
1042
1065
|
const replay = participant?.replay;
|
|
1043
1066
|
if ((replay && replay.recording) || demoModel) {
|
|
1067
|
+
// Tier badge. The recording branch reads the v2 site
|
|
1068
|
+
// (extensions['cyborg-hunter'].tier) and falls back to the structural
|
|
1069
|
+
// inference, through the SAME helper the viewer model uses — reading the
|
|
1070
|
+
// tier off a v1 `metadata` block badged every v2 recording "trace",
|
|
1071
|
+
// because v2 has no such block (the tier moved in serializer.js:145).
|
|
1072
|
+
// The demo branch takes `demoModel.tier`: `inlineReplayModels` holds
|
|
1073
|
+
// VIEWER MODELS (see the opts docblock above), and a viewer model has
|
|
1074
|
+
// never had a `metadata` block in any version — that read resolved to
|
|
1075
|
+
// "trace" for every model ever passed. Suspected-dead branch
|
|
1076
|
+
// (demo/tests/tour.spec.js:420 records that the demo stopped passing
|
|
1077
|
+
// inlineReplayModels), fixed rather than deleted.
|
|
1044
1078
|
const tier = demoModel
|
|
1045
|
-
? (demoModel.
|
|
1046
|
-
: (replay.recording
|
|
1079
|
+
? (demoModel.tier || 'trace')
|
|
1080
|
+
: inferTier(replay.recording);
|
|
1047
1081
|
// assetPath is stamped by replay-assets.js (collision-deduped filename)
|
|
1048
1082
|
// and must be preferred — recomputing from the sanitized pid here would
|
|
1049
1083
|
// resurrect the lossy-name collision the assets renderer just resolved.
|
|
@@ -1055,18 +1089,40 @@ function renderReplaySection(participant, sanitized, demoModel = null, replaySho
|
|
|
1055
1089
|
// preloadedReplayScript in renderIndexHtml), so the block is marked
|
|
1056
1090
|
// data-replay-preloaded instead of pointing the lazy loader at a file
|
|
1057
1091
|
// that doesn't exist in the sandboxed iframe.
|
|
1092
|
+
// Sheets the capture could not inline (cross-origin, fetch refused) are
|
|
1093
|
+
// href-only. Offer the fetch decision HERE, beside the one button, ticked
|
|
1094
|
+
// by default: an unstyled replay is misaligned by construction, and the
|
|
1095
|
+
// in-viewer opt-in went unread through a whole session (2026-09-03).
|
|
1096
|
+
// Rendered only when it applies, so recordings with inlined CSS keep the
|
|
1097
|
+
// exact markup the snapshot tests pin.
|
|
1098
|
+
const externalSheets = replay && replay.recording
|
|
1099
|
+
? (replay.recording.stylesheets || []).filter((sh) => sh && sh.kind === 'link' && sh.css == null).length
|
|
1100
|
+
: 0;
|
|
1101
|
+
const fetchCssLabel = externalSheets > 0
|
|
1102
|
+
? `
|
|
1103
|
+
<label class="replay-fetch-css-label"><input type="checkbox" class="replay-fetch-css" checked> also fetch ${externalSheets} external stylesheet${externalSheets === 1 ? '' : 's'} from ${externalSheets === 1 ? 'its origin' : 'their origins'} (needed for a styled, aligned replay)</label>`
|
|
1104
|
+
: '';
|
|
1058
1105
|
return `<div class="image-block replay-block" data-pid="${esc(participant.participantId)}"
|
|
1059
1106
|
${demoModel ? 'data-replay-preloaded="true"' : `data-replay-src="${esc(assetPath)}"`}>
|
|
1060
1107
|
<h4 class="section-heading">Session replay <span class="replay-note">(${esc(tier)} tier)</span></h4>
|
|
1061
1108
|
<div class="replay-mount">
|
|
1062
|
-
<button class="replay-load-btn" type="button">Load replay</button
|
|
1109
|
+
<button class="replay-load-btn" type="button">Load replay</button>${fetchCssLabel}
|
|
1063
1110
|
</div>
|
|
1064
1111
|
</div>`;
|
|
1065
1112
|
}
|
|
1066
1113
|
if (replay && replay.error) {
|
|
1114
|
+
// Two ways an attached artifact fails to reach the viewer, and the
|
|
1115
|
+
// analyst needs to tell them apart: 'parse_failed' (ingest could not read
|
|
1116
|
+
// the file at all) and 'unloadable' (the file parsed but the §11 tolerant
|
|
1117
|
+
// profile rejected it — a CH-v1 artifact is the common case, and there is
|
|
1118
|
+
// no v1 playback path). Calling a readable v1 file "corrupted" would send
|
|
1119
|
+
// the analyst looking for a truncated upload.
|
|
1120
|
+
const lead = replay.error === 'unloadable'
|
|
1121
|
+
? 'Replay artifact could not be loaded'
|
|
1122
|
+
: 'Replay artifact corrupted';
|
|
1067
1123
|
return `<div class="image-block replay-block">
|
|
1068
1124
|
<h4 class="section-heading">Session replay</h4>
|
|
1069
|
-
<p class="replay-note replay-warn"
|
|
1125
|
+
<p class="replay-note replay-warn">${lead} (${esc(replay.reason || replay.error)}) — file: ${esc(replay.file || 'unknown')}</p>
|
|
1070
1126
|
</div>`;
|
|
1071
1127
|
}
|
|
1072
1128
|
const meta = participant?.trials?.[0]?.integrityReplayMeta;
|
|
@@ -1184,7 +1240,7 @@ function renderDetailHeader(t, tier, participant, config) {
|
|
|
1184
1240
|
<div class="detail-header-top">
|
|
1185
1241
|
<span class="mono pid-full">${esc(t.participantId)}</span>
|
|
1186
1242
|
<span class="tier-pill" data-tier="${tier}">${tierLabel}</span>
|
|
1187
|
-
<span class="mono score-big">${t.score}</span>
|
|
1243
|
+
<span class="mono score-big">${formatScore(t.score)}</span>
|
|
1188
1244
|
</div>
|
|
1189
1245
|
<div class="detail-header-sub">
|
|
1190
1246
|
${s.trialCount ?? 0} trials · ${cps} cps · ${offTask} off-task
|
|
@@ -1193,9 +1249,12 @@ function renderDetailHeader(t, tier, participant, config) {
|
|
|
1193
1249
|
}
|
|
1194
1250
|
|
|
1195
1251
|
// Horizontal score breakdown — only non-zero contributions, ending in Total:N.
|
|
1196
|
-
//
|
|
1197
|
-
|
|
1198
|
-
|
|
1252
|
+
// Draws the terms rankTriage actually summed (t.terms). Rows built elsewhere
|
|
1253
|
+
// without terms (hand-built callers) are decomposed here with the CONFIGURED
|
|
1254
|
+
// weights, so the bars never disagree with a custom-weights header.
|
|
1255
|
+
function renderScoreBreakdown(t, config) {
|
|
1256
|
+
const terms = (t.terms ?? decomposeScore(t.summary || {}, t.edgeExitCount, t.hardTriggered,
|
|
1257
|
+
resolveScoreWeights(config?.scoreWeights).weights))
|
|
1199
1258
|
.filter(([, n]) => n > 0);
|
|
1200
1259
|
|
|
1201
1260
|
const total = t.score;
|
|
@@ -1205,17 +1264,24 @@ function renderScoreBreakdown(t) {
|
|
|
1205
1264
|
const termHtml = terms.map(([label, n]) =>
|
|
1206
1265
|
`<span class="score-term">
|
|
1207
1266
|
<span class="label">${label}</span>
|
|
1208
|
-
<span class="mono contrib">+${n}</span>
|
|
1267
|
+
<span class="mono contrib">+${formatScore(n)}</span>
|
|
1209
1268
|
<span class="bar" style="width:${barFor(n)}px"></span>
|
|
1210
1269
|
</span>`
|
|
1211
1270
|
).join('');
|
|
1212
1271
|
|
|
1213
1272
|
return `<div class="score-breakdown">
|
|
1214
1273
|
${termHtml}
|
|
1215
|
-
<span class="score-total mono">Total: ${total}</span>
|
|
1274
|
+
<span class="score-total mono">Total: ${formatScore(total)}</span>
|
|
1216
1275
|
</div>`;
|
|
1217
1276
|
}
|
|
1218
1277
|
|
|
1278
|
+
// Top-bar note, present only when config.scoreWeights changes the score from
|
|
1279
|
+
// the defaults: " · custom score weights: copy 5 max 3, synthetic 1".
|
|
1280
|
+
function scoreWeightsNote(config) {
|
|
1281
|
+
const { weights, isDefault } = resolveScoreWeights(config?.scoreWeights);
|
|
1282
|
+
return isDefault ? '' : ` · custom score weights: ${esc(customWeightsText(weights))}`;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1219
1285
|
// Format milliseconds as "Xs" / "Xm Ys".
|
|
1220
1286
|
function formatDuration(ms) {
|
|
1221
1287
|
if (!ms || ms < 1000) return '0s';
|
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
// Thin Node wrapper around the pure render core (html-index-core.js):
|
|
3
3
|
// reads the replay viewer client from disk, writes index.html to outputDir.
|
|
4
4
|
// Public API unchanged — report.js and adopters keep calling renderHtmlIndex.
|
|
5
|
-
import { writeFileSync
|
|
5
|
+
import { writeFileSync } from 'fs';
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
import { renderIndexHtml } from './html-index-core.js';
|
|
8
|
+
import { readReplayClientSrc } from './replay-client-source.js';
|
|
8
9
|
|
|
9
|
-
// The replay viewer client is developed as
|
|
10
|
+
// The replay viewer client is developed as real JS files (linted, syntax-
|
|
10
11
|
// highlighted) and embedded verbatim at render time — same file://-safe
|
|
11
|
-
// output as
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// output as an inline IIFE, without string-blob development pain. It is a
|
|
13
|
+
// CONCATENATION: the client calls into src/replay/dom-instantiate.js, which
|
|
14
|
+
// ships ahead of it in the same script (see replay-client-source.js).
|
|
15
|
+
const REPLAY_CLIENT_SRC = readReplayClientSrc();
|
|
14
16
|
|
|
15
17
|
export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
|
|
16
18
|
const html = await renderIndexHtml(summaries, triage, participants, config,
|
|
@@ -16,6 +16,7 @@ import { join } from 'path';
|
|
|
16
16
|
|
|
17
17
|
import { sanitizeId as sanitize } from '../../shared/constants.js';
|
|
18
18
|
import { buildViewerModel } from '../../replay/viewer-model.js';
|
|
19
|
+
import { inlineSafeJson } from '../../shared/inline-safe.js';
|
|
19
20
|
|
|
20
21
|
// Load-bearing re-export: renderReplayAssets (below) calls buildViewerModel
|
|
21
22
|
// in-file, and tests/cli/replay-render.test.js + tests/replay/alignment-viewer-model.test.js
|
|
@@ -24,17 +25,55 @@ export { buildViewerModel };
|
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Writes replay/<sanitizedPid>.replay.js for every participant with an
|
|
27
|
-
* attached recording. Returns { count, totalBytes } so report.js can
|
|
28
|
-
* an honest size line (replay assets dominate report size at dom tier)
|
|
28
|
+
* attached recording. Returns { count, totalBytes, skipped } so report.js can
|
|
29
|
+
* print an honest size line (replay assets dominate report size at dom tier)
|
|
30
|
+
* and an honest line about what did not make it.
|
|
31
|
+
*
|
|
32
|
+
* `skipped` is [{participantId, file, reason}] — one entry per artifact that
|
|
33
|
+
* exists, reads fine, and still never reaches the viewer. Two things produce
|
|
34
|
+
* one, and they are the same event seen at different layers:
|
|
35
|
+
*
|
|
36
|
+
* - the §11 tolerant profile rejecting a recording here. buildViewerModel
|
|
37
|
+
* THROWS on that set (it is v2-only; a CH-v1 artifact is the common case),
|
|
38
|
+
* and the alternative to catching is that one unloadable file in a cohort
|
|
39
|
+
* aborts the whole report — precisely the data-loss trade §11 exists to
|
|
40
|
+
* refuse. v1 degraded and rendered the rest; v2 skips the participant and
|
|
41
|
+
* says so.
|
|
42
|
+
* - a refusal already stamped by INGEST (A3): a jsPsych v1 recording the
|
|
43
|
+
* converter would not migrate, which never gets as far as a model. Task
|
|
44
|
+
* 10(b) settled that a replay which does not make it is visible on three
|
|
45
|
+
* surfaces, and the CLI line report.js prints from this list is one of
|
|
46
|
+
* them, so both origins have to be accounted for here or the console is a
|
|
47
|
+
* partial account.
|
|
48
|
+
*
|
|
49
|
+
* A corrupted file (ingest's `parse_failed`) deliberately stays out: it keeps
|
|
50
|
+
* its own report lead text and its own ingest warning.
|
|
51
|
+
*
|
|
52
|
+
* The say-so is a stamp on `p.replay`, read by renderReplaySection's error
|
|
53
|
+
* branch — the report's existing state for "attached but not viewable".
|
|
54
|
+
* Repeated calls now return the same list (both classes are recognised from
|
|
55
|
+
* the stamp), which retires the non-idempotence noted at T5 Task 10 review
|
|
56
|
+
* M-5. One caller today (`report.js:149`), which renders the index from the
|
|
57
|
+
* same array.
|
|
29
58
|
*/
|
|
30
59
|
export function renderReplayAssets(participants, outputDir) {
|
|
31
60
|
let count = 0;
|
|
32
61
|
let totalBytes = 0;
|
|
62
|
+
const skipped = [];
|
|
63
|
+
for (const p of participants) {
|
|
64
|
+
if (p.replay && !p.replay.recording && p.replay.error === 'unloadable') {
|
|
65
|
+
skipped.push({ participantId: p.participantId, file: p.replay.file || null,
|
|
66
|
+
reason: p.replay.reason || p.replay.error });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
33
69
|
const withReplay = participants.filter((p) => p.replay && p.replay.recording);
|
|
34
|
-
if (withReplay.length === 0) return { count, totalBytes };
|
|
70
|
+
if (withReplay.length === 0) return { count, totalBytes, skipped };
|
|
35
71
|
|
|
72
|
+
// Created on the first successful write, not up front: a cohort whose
|
|
73
|
+
// every artifact is unloadable would otherwise ship an empty replay/ dir
|
|
74
|
+
// beside a report that says there is nothing to load.
|
|
36
75
|
const replayDir = join(outputDir, 'replay');
|
|
37
|
-
|
|
76
|
+
let dirMade = false;
|
|
38
77
|
|
|
39
78
|
// Sanitization is lossy ('a/b' and 'a_b' both map to a_b) — dedupe with a
|
|
40
79
|
// stable numeric suffix so a later write can never overwrite an earlier
|
|
@@ -42,7 +81,21 @@ export function renderReplayAssets(participants, outputDir) {
|
|
|
42
81
|
// (replay.assetPath) and consumed by html-index, which must not recompute.
|
|
43
82
|
const usedNames = new Set();
|
|
44
83
|
for (const p of withReplay) {
|
|
45
|
-
|
|
84
|
+
let model;
|
|
85
|
+
try {
|
|
86
|
+
model = buildViewerModel(p.replay.recording);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// Skip this participant, keep the cohort. The recording is dropped from
|
|
89
|
+
// the participant so the index cannot render a Load button for an asset
|
|
90
|
+
// that was never written; `error: 'unloadable'` distinguishes it from
|
|
91
|
+
// ingest's 'parse_failed' (a file that could not even be read).
|
|
92
|
+
// `e.reasons` is the §11 profile's own list; the message wrapper adds
|
|
93
|
+
// a function name the analyst has no use for.
|
|
94
|
+
const reason = Array.isArray(e.reasons) ? e.reasons.join('; ') : e.message;
|
|
95
|
+
skipped.push({ participantId: p.participantId, file: p.replay.file || null, reason });
|
|
96
|
+
p.replay = { error: 'unloadable', reason, file: p.replay.file || null };
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
46
99
|
// The store is keyed by the RAW participant id (what the report's
|
|
47
100
|
// loader passes); the filename uses the sanitized form.
|
|
48
101
|
// Null-prototype store: participant ids are untrusted, and a pid like
|
|
@@ -51,17 +104,18 @@ export function renderReplayAssets(participants, outputDir) {
|
|
|
51
104
|
const src =
|
|
52
105
|
'window.__chReplay = window.__chReplay || Object.create(null);\n' +
|
|
53
106
|
'window.__chReplay[' + JSON.stringify(String(p.participantId)) + '] = ' +
|
|
54
|
-
|
|
107
|
+
inlineSafeJson(model) + ';\n';
|
|
55
108
|
let base = sanitize(p.participantId);
|
|
56
109
|
let name = base + '.replay.js';
|
|
57
110
|
for (let n = 2; usedNames.has(name.toLowerCase()); n++) {
|
|
58
111
|
name = base + '~' + n + '.replay.js';
|
|
59
112
|
}
|
|
60
113
|
usedNames.add(name.toLowerCase());
|
|
114
|
+
if (!dirMade) { mkdirSync(replayDir, { recursive: true }); dirMade = true; }
|
|
61
115
|
writeFileSync(join(replayDir, name), src);
|
|
62
116
|
p.replay.assetPath = 'replay/' + name;
|
|
63
117
|
count++;
|
|
64
118
|
totalBytes += Buffer.byteLength(src);
|
|
65
119
|
}
|
|
66
|
-
return { count, totalBytes };
|
|
120
|
+
return { count, totalBytes, skipped };
|
|
67
121
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/cli/renderers/replay-client-source.js
|
|
2
|
+
// The report's viewer script, ASSEMBLED — the one place that knows the viewer
|
|
3
|
+
// client is a concatenation rather than a file.
|
|
4
|
+
//
|
|
5
|
+
// T5 Task 2 recorded the decision this module executes: `replay-viewer.client.js`
|
|
6
|
+
// calls `mountTree`/`applyPatch`/`applyPatches` out of `src/replay/dom-instantiate.js`,
|
|
7
|
+
// and the client is a plain IIFE inlined verbatim into an HTML report — it
|
|
8
|
+
// cannot `import`. The two ways to give it those functions are (a) the client
|
|
9
|
+
// carries its own copy, which would put two readings of spec §4 in the repo,
|
|
10
|
+
// which is the failure this migration exists to remove, or (b) the build
|
|
11
|
+
// concatenates the module ahead of the client with its single `export` line
|
|
12
|
+
// stripped. (b) is the decision, and `tests/replay/dom-instantiate.test.js`
|
|
13
|
+
// machine-checks that the module stays concatenable — no imports, exactly one
|
|
14
|
+
// strippable ESM statement, strict-safe.
|
|
15
|
+
//
|
|
16
|
+
// The concatenation is wrapped in ONE `'use strict'` IIFE so the module's ~35
|
|
17
|
+
// top-level `var`s and helper functions (`bind`, `resolve`, `result`, …) never
|
|
18
|
+
// reach the report page's global scope, where they would collide with the
|
|
19
|
+
// report's own scripts. The client's own IIFE nests inside it and closes over
|
|
20
|
+
// them; `window.initChReplayViewer` is still assigned, which is the whole
|
|
21
|
+
// public surface.
|
|
22
|
+
//
|
|
23
|
+
// FIVE consumers were surveyed at Task 2 and are routed as follows:
|
|
24
|
+
// html-index.js → this module (the shipped report)
|
|
25
|
+
// tools/assemble-demo-site.mjs → this module (writes the ASSEMBLED file,
|
|
26
|
+
// which demo/results.js fetches by name)
|
|
27
|
+
// cursor-alignment.battery.mjs → this module (Task 8's harness)
|
|
28
|
+
// tools/investigate/probe-support.mjs → NOT a consumer of the assembly: it
|
|
29
|
+
// extracts the literal `srcdocCsp()` out
|
|
30
|
+
// of the client source and needs the raw
|
|
31
|
+
// file, not the bundle.
|
|
32
|
+
// tools/investigate/cursor-alignment-probe.mjs → superseded (Task 1).
|
|
33
|
+
|
|
34
|
+
import { readFileSync } from 'fs';
|
|
35
|
+
|
|
36
|
+
import { inlineSrcHazards } from '../../shared/inline-safe.js';
|
|
37
|
+
|
|
38
|
+
// The one ESM statement `dom-instantiate.js` is allowed to carry. The same
|
|
39
|
+
// literal appears in that module's own concatenability test; if it drifts, this
|
|
40
|
+
// throws rather than shipping a report whose viewer has a stray `export`.
|
|
41
|
+
const EXPORT_BLOCK = /^export \{[^}]*\};$/m;
|
|
42
|
+
|
|
43
|
+
const MODULE_URL = new URL('../../replay/dom-instantiate.js', import.meta.url);
|
|
44
|
+
const CLIENT_URL = new URL('./replay-viewer.client.js', import.meta.url);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The assembly itself, over sources rather than paths, so every contract the
|
|
48
|
+
* assembled script has to satisfy can be exercised with a constructed
|
|
49
|
+
* violation instead of by editing shipped files.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} mod src/replay/dom-instantiate.js
|
|
52
|
+
* @param {string} client src/cli/renderers/replay-viewer.client.js
|
|
53
|
+
* @returns {string} the two inside one strict IIFE
|
|
54
|
+
* @throws {Error} on any of the three contract violations below
|
|
55
|
+
*/
|
|
56
|
+
export function assembleReplayClientSrc(mod, client) {
|
|
57
|
+
if (!EXPORT_BLOCK.test(mod)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'replay-client-source: dom-instantiate.js no longer ends in a single strippable ' +
|
|
60
|
+
'`export { … };` line — the concatenation contract (T5 Task 2) is broken. ' +
|
|
61
|
+
'Fix the module, not this assembler.');
|
|
62
|
+
}
|
|
63
|
+
if (/^\s*import\b/m.test(mod)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'replay-client-source: dom-instantiate.js grew an import; a plain script ' +
|
|
66
|
+
'concatenation cannot resolve it.');
|
|
67
|
+
}
|
|
68
|
+
const out = "(function () {\n'use strict';\n" +
|
|
69
|
+
mod.replace(EXPORT_BLOCK, '') + '\n' +
|
|
70
|
+
client + '\n})();\n';
|
|
71
|
+
// Third contract, and the reason it is HERE (T5 Task 10 fix round 3, review
|
|
72
|
+
// I-1): every consumer of this function inlines the result into an HTML
|
|
73
|
+
// `<script>`, and `inlineSafeSrc` — the rule they all apply — neutralises
|
|
74
|
+
// `</script` and cannot neutralise the script-data-ESCAPED breakout. An
|
|
75
|
+
// unpaired `<!--` followed by a `<script`, in a comment or a string, makes
|
|
76
|
+
// the parser swallow the page's own closing tag: the viewer never boots and
|
|
77
|
+
// NOTHING is logged. That is strictly worse than the truncation Task 10
|
|
78
|
+
// fixed, which at least raised a SyntaxError. The escape cannot be widened
|
|
79
|
+
// safely (see inline-safe.js), so the precondition is asserted instead —
|
|
80
|
+
// loudly, at build time, on the day a comment mentioning HTML lands.
|
|
81
|
+
const hazards = inlineSrcHazards(out);
|
|
82
|
+
if (hazards.length) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'replay-client-source: the assembled viewer cannot be safely inlined into a ' +
|
|
85
|
+
'<script> element — it ' + hazards.join('; and it ') + '. Such a sequence puts ' +
|
|
86
|
+
'the HTML parser into script-data-escaped state, where the report\'s own ' +
|
|
87
|
+
'</script> no longer closes the element and the viewer silently never boots. ' +
|
|
88
|
+
'Reword the source (a comment mentioning HTML is the usual cause); do NOT ' +
|
|
89
|
+
'widen the escape, which would change the meaning of regex literals.');
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @returns {string} the viewer script as the report inlines it: the §4
|
|
96
|
+
* instantiation/patch module and the viewer client inside one strict IIFE.
|
|
97
|
+
*/
|
|
98
|
+
export function readReplayClientSrc() {
|
|
99
|
+
return assembleReplayClientSrc(
|
|
100
|
+
readFileSync(MODULE_URL, 'utf8'),
|
|
101
|
+
readFileSync(CLIENT_URL, 'utf8'));
|
|
102
|
+
}
|