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.
Files changed (37) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/package.json +10 -2
  9. package/src/cli/ingest.js +311 -57
  10. package/src/cli/renderers/html-index-core.js +66 -12
  11. package/src/cli/renderers/html-index.js +7 -5
  12. package/src/cli/renderers/replay-assets.js +61 -7
  13. package/src/cli/renderers/replay-client-source.js +102 -0
  14. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  15. package/src/cli/report.js +6 -0
  16. package/src/core/monitor.js +12 -13
  17. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  18. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  19. package/src/replay/capture-dom.js +470 -458
  20. package/src/replay/capture-trace.js +688 -271
  21. package/src/replay/delivery.js +82 -0
  22. package/src/replay/dom-instantiate.js +779 -0
  23. package/src/replay/index.js +88 -5
  24. package/src/replay/initial-state.js +295 -0
  25. package/src/replay/mutations.js +668 -0
  26. package/src/replay/node-registry.js +116 -0
  27. package/src/replay/persistence.js +19 -6
  28. package/src/replay/recorder.js +342 -73
  29. package/src/replay/redaction.js +165 -0
  30. package/src/replay/serializer.js +148 -43
  31. package/src/replay/snapshot.js +409 -0
  32. package/src/replay/span.js +55 -0
  33. package/src/replay/viewer-model.js +293 -102
  34. package/src/shared/constants.js +1 -1
  35. package/src/shared/inline-safe.js +80 -0
  36. package/src/shared/schema-v2-validator.js +595 -0
  37. 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
- const replayClientSrc = opts.replayClientSrc ?? '';
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
- // '<' chars are escaped to a six-character JS unicode escape sequence (not
56
- // the literal char) so a "</script>" inside the JSON can't prematurely
57
- // close this tag.
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 = ${JSON.stringify(inlineReplayModels).replace(/</g, '\\u003c')};</script>\n `
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-trial-select, .replay-speed {
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.metadata?.tier || 'trace')
1046
- : (replay.recording.metadata?.tier || 'trace');
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">Replay artifact corrupted (${esc(replay.reason || replay.error)}) — file: ${esc(replay.file || 'unknown')}</p>
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, readFileSync } from 'fs';
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 a real JS file (linted, syntax-
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 the inline IIFE, without string-blob development pain.
12
- const REPLAY_CLIENT_SRC = readFileSync(
13
- new URL('./replay-viewer.client.js', import.meta.url), 'utf8');
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 print
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
- mkdirSync(replayDir, { recursive: true });
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
- const model = buildViewerModel(p.replay.recording);
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
- JSON.stringify(model).replace(/</g, '\\u003c') + ';\n';
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
+ }