cyborg-hunter 0.7.5 → 0.8.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 +51 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -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/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- 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/report.js +6 -0
- 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/tools/convert/jspsych-v1-to-v2.mjs +432 -0
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
import { VERSION, sanitizeId } from '../../shared/constants.js';
|
|
23
23
|
import { decomposeScore } from '../analyzers/triage.js';
|
|
24
24
|
import { getByPath } from '../../shared/paths.js';
|
|
25
|
+
import { inferTier } from '../../replay/viewer-model.js';
|
|
26
|
+
import { inlineSafeJson, inlineSafeSrc } from '../../shared/inline-safe.js';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Renders the report's index.html as a string. `opts.replayClientSrc` is the
|
|
@@ -33,7 +35,14 @@ import { getByPath } from '../../shared/paths.js';
|
|
|
33
35
|
* pre-split renderHtmlIndex always rendered).
|
|
34
36
|
*/
|
|
35
37
|
export async function renderIndexHtml(summaries, triage, participants, config, visualsRendered, opts = {}) {
|
|
36
|
-
|
|
38
|
+
// Inlining JS into an HTML <script> means owning the one sequence the HTML
|
|
39
|
+
// parser reacts to (see src/shared/inline-safe.js for both rules and the
|
|
40
|
+
// precondition the source rule carries). Without it, one comment discussing
|
|
41
|
+
// script-end-tag breakouts — two of the v2 replay modules have one —
|
|
42
|
+
// truncates the whole viewer and the report boots with a SyntaxError. This
|
|
43
|
+
// was hand-rolled in nine places and missing from exactly this one, which is
|
|
44
|
+
// why the rule now lives in one module (T5 Task 10 + its fix round 3).
|
|
45
|
+
const replayClientSrc = inlineSafeSrc(opts.replayClientSrc);
|
|
37
46
|
const visualsUnavailableNote = opts.visualsUnavailableNote
|
|
38
47
|
?? 'Visual renderers not available (install the canvas package).';
|
|
39
48
|
const imageSources = opts.imageSources ?? null; // pid → {typingProfile, sessionTimeline, trajectories} data URIs (null entry = omit that img)
|
|
@@ -52,11 +61,11 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
|
|
|
52
61
|
|
|
53
62
|
// Preloaded replay models (demo mode only) — embedded ahead of the replay
|
|
54
63
|
// client script so window.__chReplay exists before any viewer code runs.
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
64
|
+
// This is DATA, so it takes the every-`<` rule (inline-safe.js rule 1),
|
|
65
|
+
// which is both lossless inside a JSON string and complete: no `<` survives
|
|
66
|
+
// for the HTML tokenizer to react to, in any of its states.
|
|
58
67
|
const preloadedReplayScript = inlineReplayModels
|
|
59
|
-
? `<script>/* preloaded replay models (demo mode) */window.__chReplay = ${
|
|
68
|
+
? `<script>/* preloaded replay models (demo mode) */window.__chReplay = ${inlineSafeJson(inlineReplayModels)};</script>\n `
|
|
60
69
|
: '';
|
|
61
70
|
|
|
62
71
|
// Hash-sync emission for the rail click handler (selectById, below): demo
|
|
@@ -740,11 +749,13 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
|
|
|
740
749
|
.replay-note { font-size: 12px; color: var(--dim); }
|
|
741
750
|
.replay-warn { color: var(--hard); }
|
|
742
751
|
/* Controls share the report's flat, line-bordered button style */
|
|
743
|
-
.replay-play, .replay-load-btn, .replay-css-btn, .replay-
|
|
752
|
+
.replay-play, .replay-load-btn, .replay-css-btn, .replay-segment-select, .replay-speed {
|
|
744
753
|
padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
|
|
745
754
|
color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
|
|
746
755
|
.replay-play:hover, .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
|
|
747
756
|
.replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
|
|
757
|
+
.replay-fetch-css-label { margin-left: 10px; font-size: 12px; color: #555; }
|
|
758
|
+
.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
759
|
.replay-css-btn { font-size: 12px; padding: 2px 8px; }
|
|
749
760
|
.replay-clock { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
|
|
750
761
|
font-variant-numeric: tabular-nums; }
|
|
@@ -754,6 +765,13 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
|
|
|
754
765
|
background: rgba(0,0,0,0.72); color: #fff; padding: 2px 7px;
|
|
755
766
|
border-radius: 3px; white-space: nowrap; }
|
|
756
767
|
.replay-key-chip--redacted { background: rgba(0,0,0,0.5); font-style: italic; }
|
|
768
|
+
/* Media is reported as state, never played (design §7): the badges say what
|
|
769
|
+
the recorded element was doing, in the stage's top-left. */
|
|
770
|
+
.replay-media { position: absolute; left: 0; top: 0; display: flex; gap: 4px;
|
|
771
|
+
padding: 5px 6px; pointer-events: none; flex-wrap: wrap; }
|
|
772
|
+
.replay-media-badge { font: 11px/1.2 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
|
|
773
|
+
background: rgba(0,137,123,0.85); color: #fff; padding: 2px 7px;
|
|
774
|
+
border-radius: 3px; white-space: nowrap; }
|
|
757
775
|
</style>
|
|
758
776
|
${preloadedReplayScript}<script>
|
|
759
777
|
${replayClientSrc}
|
|
@@ -770,6 +788,9 @@ ${replayClientSrc}
|
|
|
770
788
|
const src = block.dataset.replaySrc;
|
|
771
789
|
const pid = block.dataset.pid;
|
|
772
790
|
const mount = block.querySelector('.replay-mount');
|
|
791
|
+
// Read the fetch decision before the mount is cleared.
|
|
792
|
+
const fetchBox = block.querySelector('.replay-fetch-css');
|
|
793
|
+
const viewerOpts = { externalCss: !!(fetchBox && fetchBox.checked) };
|
|
773
794
|
btn.disabled = true;
|
|
774
795
|
btn.textContent = 'Loading…';
|
|
775
796
|
mount.setAttribute('aria-busy', 'true');
|
|
@@ -788,7 +809,7 @@ ${replayClientSrc}
|
|
|
788
809
|
if (preloaded) {
|
|
789
810
|
mount.removeAttribute('aria-busy');
|
|
790
811
|
mount.textContent = '';
|
|
791
|
-
window.initChReplayViewer(mount, preloaded);
|
|
812
|
+
window.initChReplayViewer(mount, preloaded, viewerOpts);
|
|
792
813
|
return;
|
|
793
814
|
}
|
|
794
815
|
` : ''}const s = document.createElement('script');
|
|
@@ -798,7 +819,7 @@ ${replayClientSrc}
|
|
|
798
819
|
const model = (window.__chReplay || {})[pid];
|
|
799
820
|
if (!model) { fail('Replay data failed to load (' + src + ').'); return; }
|
|
800
821
|
mount.textContent = '';
|
|
801
|
-
window.initChReplayViewer(mount, model);
|
|
822
|
+
window.initChReplayViewer(mount, model, viewerOpts);
|
|
802
823
|
};
|
|
803
824
|
s.onerror = function () {
|
|
804
825
|
fail('Replay asset missing (' + src + ') — was the report generated with the replay artifacts present?');
|
|
@@ -1041,9 +1062,20 @@ function renderReplaySection(participant, sanitized, demoModel = null, replaySho
|
|
|
1041
1062
|
if (!participant) return '';
|
|
1042
1063
|
const replay = participant?.replay;
|
|
1043
1064
|
if ((replay && replay.recording) || demoModel) {
|
|
1065
|
+
// Tier badge. The recording branch reads the v2 site
|
|
1066
|
+
// (extensions['cyborg-hunter'].tier) and falls back to the structural
|
|
1067
|
+
// inference, through the SAME helper the viewer model uses — reading the
|
|
1068
|
+
// tier off a v1 `metadata` block badged every v2 recording "trace",
|
|
1069
|
+
// because v2 has no such block (the tier moved in serializer.js:145).
|
|
1070
|
+
// The demo branch takes `demoModel.tier`: `inlineReplayModels` holds
|
|
1071
|
+
// VIEWER MODELS (see the opts docblock above), and a viewer model has
|
|
1072
|
+
// never had a `metadata` block in any version — that read resolved to
|
|
1073
|
+
// "trace" for every model ever passed. Suspected-dead branch
|
|
1074
|
+
// (demo/tests/tour.spec.js:420 records that the demo stopped passing
|
|
1075
|
+
// inlineReplayModels), fixed rather than deleted.
|
|
1044
1076
|
const tier = demoModel
|
|
1045
|
-
? (demoModel.
|
|
1046
|
-
: (replay.recording
|
|
1077
|
+
? (demoModel.tier || 'trace')
|
|
1078
|
+
: inferTier(replay.recording);
|
|
1047
1079
|
// assetPath is stamped by replay-assets.js (collision-deduped filename)
|
|
1048
1080
|
// and must be preferred — recomputing from the sanitized pid here would
|
|
1049
1081
|
// resurrect the lossy-name collision the assets renderer just resolved.
|
|
@@ -1055,18 +1087,40 @@ function renderReplaySection(participant, sanitized, demoModel = null, replaySho
|
|
|
1055
1087
|
// preloadedReplayScript in renderIndexHtml), so the block is marked
|
|
1056
1088
|
// data-replay-preloaded instead of pointing the lazy loader at a file
|
|
1057
1089
|
// that doesn't exist in the sandboxed iframe.
|
|
1090
|
+
// Sheets the capture could not inline (cross-origin, fetch refused) are
|
|
1091
|
+
// href-only. Offer the fetch decision HERE, beside the one button, ticked
|
|
1092
|
+
// by default: an unstyled replay is misaligned by construction, and the
|
|
1093
|
+
// in-viewer opt-in went unread through a whole session (2026-09-03).
|
|
1094
|
+
// Rendered only when it applies, so recordings with inlined CSS keep the
|
|
1095
|
+
// exact markup the snapshot tests pin.
|
|
1096
|
+
const externalSheets = replay && replay.recording
|
|
1097
|
+
? (replay.recording.stylesheets || []).filter((sh) => sh && sh.kind === 'link' && sh.css == null).length
|
|
1098
|
+
: 0;
|
|
1099
|
+
const fetchCssLabel = externalSheets > 0
|
|
1100
|
+
? `
|
|
1101
|
+
<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>`
|
|
1102
|
+
: '';
|
|
1058
1103
|
return `<div class="image-block replay-block" data-pid="${esc(participant.participantId)}"
|
|
1059
1104
|
${demoModel ? 'data-replay-preloaded="true"' : `data-replay-src="${esc(assetPath)}"`}>
|
|
1060
1105
|
<h4 class="section-heading">Session replay <span class="replay-note">(${esc(tier)} tier)</span></h4>
|
|
1061
1106
|
<div class="replay-mount">
|
|
1062
|
-
<button class="replay-load-btn" type="button">Load replay</button
|
|
1107
|
+
<button class="replay-load-btn" type="button">Load replay</button>${fetchCssLabel}
|
|
1063
1108
|
</div>
|
|
1064
1109
|
</div>`;
|
|
1065
1110
|
}
|
|
1066
1111
|
if (replay && replay.error) {
|
|
1112
|
+
// Two ways an attached artifact fails to reach the viewer, and the
|
|
1113
|
+
// analyst needs to tell them apart: 'parse_failed' (ingest could not read
|
|
1114
|
+
// the file at all) and 'unloadable' (the file parsed but the §11 tolerant
|
|
1115
|
+
// profile rejected it — a CH-v1 artifact is the common case, and there is
|
|
1116
|
+
// no v1 playback path). Calling a readable v1 file "corrupted" would send
|
|
1117
|
+
// the analyst looking for a truncated upload.
|
|
1118
|
+
const lead = replay.error === 'unloadable'
|
|
1119
|
+
? 'Replay artifact could not be loaded'
|
|
1120
|
+
: 'Replay artifact corrupted';
|
|
1067
1121
|
return `<div class="image-block replay-block">
|
|
1068
1122
|
<h4 class="section-heading">Session replay</h4>
|
|
1069
|
-
<p class="replay-note replay-warn"
|
|
1123
|
+
<p class="replay-note replay-warn">${lead} (${esc(replay.reason || replay.error)}) — file: ${esc(replay.file || 'unknown')}</p>
|
|
1070
1124
|
</div>`;
|
|
1071
1125
|
}
|
|
1072
1126
|
const meta = participant?.trials?.[0]?.integrityReplayMeta;
|
|
@@ -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
|
+
}
|