cyborg-hunter 0.5.0 → 0.7.2

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 (58) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +80 -21
  5. package/bin/cyborg-hunter.js +6 -2
  6. package/dist/cyborg-hunter-replay.js +3 -0
  7. package/dist/cyborg-hunter.esm.js +114 -22
  8. package/dist/cyborg-hunter.min.js +3 -3
  9. package/dist/extension-cyborg-hunter.js +1 -1
  10. package/dist/extension-guard-friction.js +5 -5
  11. package/dist/extension-guard-honeypot.js +1 -1
  12. package/package.json +15 -3
  13. package/src/cli/analyzers/edge-exit.js +4 -1
  14. package/src/cli/analyzers/phase-scope.js +83 -0
  15. package/src/cli/analyzers/summary.js +161 -28
  16. package/src/cli/analyzers/triage.js +59 -27
  17. package/src/cli/config.js +26 -1
  18. package/src/cli/extract-core.js +552 -0
  19. package/src/cli/ingest.js +250 -193
  20. package/src/cli/init.js +1 -1
  21. package/src/cli/preview-entry.js +36 -0
  22. package/src/cli/renderers/event-log.js +18 -19
  23. package/src/cli/renderers/extensions.js +12 -3
  24. package/src/cli/renderers/html-index-core.js +1273 -0
  25. package/src/cli/renderers/html-index.js +13 -1047
  26. package/src/cli/renderers/replay-assets.js +67 -0
  27. package/src/cli/renderers/replay-viewer.client.js +1185 -0
  28. package/src/cli/renderers/session-timeline-core.js +907 -0
  29. package/src/cli/renderers/session-timeline.js +33 -0
  30. package/src/cli/renderers/summary-csv.js +5 -0
  31. package/src/cli/renderers/trajectories-core.js +717 -0
  32. package/src/cli/renderers/trajectories.js +31 -635
  33. package/src/cli/renderers/triage-md.js +10 -4
  34. package/src/cli/renderers/typing-profile-core.js +211 -0
  35. package/src/cli/renderers/typing-profile.js +16 -186
  36. package/src/cli/report.js +42 -8
  37. package/src/core/monitor.js +77 -7
  38. package/src/core/scoring.js +11 -2
  39. package/src/core/signals/browser.js +51 -18
  40. package/src/core/signals/clipboard.js +10 -2
  41. package/src/core/signals/dom-protection.js +9 -0
  42. package/src/core/signals/focus.js +16 -2
  43. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  44. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  45. package/src/jspsych/extension-guard-friction.js +43 -14
  46. package/src/jspsych/extension-guard-honeypot.js +25 -1
  47. package/src/replay/capture-dom.js +575 -0
  48. package/src/replay/capture-trace.js +468 -0
  49. package/src/replay/index.js +104 -0
  50. package/src/replay/persistence.js +141 -0
  51. package/src/replay/recorder.js +315 -0
  52. package/src/replay/serializer.js +119 -0
  53. package/src/replay/viewer-model.js +125 -0
  54. package/src/shared/constants.js +12 -6
  55. package/src/shared/paths.js +20 -0
  56. package/src/shared/schema.js +5 -0
  57. package/src/shared/validation.js +55 -0
  58. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -56,6 +56,22 @@
56
56
  //
57
57
  // ============================================================
58
58
 
59
+ // Prefix-aware fullscreen-element lookup. Safari <16.4 and some iOS WebViews
60
+ // expose ONLY document.webkitFullscreenElement (and fire 'webkitfullscreenchange');
61
+ // older Firefox used the moz-prefixed API. requestFullscreen() below already
62
+ // falls through these prefixes, so the authoritative check()/start()/settling
63
+ // gates MUST too — otherwise a participant who successfully enters fullscreen on
64
+ // a prefix-only browser reads a null unprefixed element and is trapped behind a
65
+ // permanent false 'not_fullscreen' violation. Pure + exported for unit testing.
66
+ export function fullscreenElementOf(doc) {
67
+ doc = doc || (typeof document !== 'undefined' ? document : null);
68
+ if (!doc) return null;
69
+ return doc.fullscreenElement
70
+ || doc.webkitFullscreenElement
71
+ || doc.mozFullScreenElement
72
+ || null;
73
+ }
74
+
59
75
  (function (global) {
60
76
  'use strict';
61
77
 
@@ -143,10 +159,7 @@
143
159
  }
144
160
 
145
161
  function getDiagnostics() {
146
- const fsEl = document.fullscreenElement
147
- || document.webkitFullscreenElement
148
- || document.mozFullScreenElement
149
- || null;
162
+ const fsEl = fullscreenElementOf(document);
150
163
  const baselineDeltaCssPx = computeBaselineDeltaCssPx();
151
164
 
152
165
  return {
@@ -487,7 +500,7 @@
487
500
  function check() {
488
501
  const diagnostics = getDiagnostics();
489
502
 
490
- if (!document.fullscreenElement) {
503
+ if (!fullscreenElementOf(document)) {
491
504
  return { ok: false, reason: 'not_fullscreen', diagnostics: diagnostics };
492
505
  }
493
506
  if (document.visibilityState !== 'visible') {
@@ -689,7 +702,7 @@
689
702
  // re-capture baseline against the now-settled DPR. Fixes
690
703
  // a Chrome-on-Retina case where DPR is briefly stale on
691
704
  // the frame where fullscreenchange fires.
692
- if (document.fullscreenElement && performance.now() < state.rebaselineUntil) {
705
+ if (fullscreenElementOf(document) && performance.now() < state.rebaselineUntil) {
693
706
  state.rebaselineUntil = 0;
694
707
  captureBaseline(function () { update('event.resize.rebaseline'); });
695
708
  return;
@@ -699,6 +712,11 @@
699
712
  };
700
713
 
701
714
  _addEventListener.call(document, 'fullscreenchange', state.eventHandlers.fullscreenchange);
715
+ // Prefixed variants for browsers that only fire the vendor event
716
+ // (Safari <16.4, some iOS WebViews, older Firefox). Same handler, so
717
+ // baseline capture / re-check happens regardless of which one fires.
718
+ _addEventListener.call(document, 'webkitfullscreenchange', state.eventHandlers.fullscreenchange);
719
+ _addEventListener.call(document, 'mozfullscreenchange', state.eventHandlers.fullscreenchange);
702
720
  _addEventListener.call(document, 'visibilitychange', state.eventHandlers.visibilitychange);
703
721
  _addEventListener.call(window, 'blur', state.eventHandlers.blur);
704
722
  _addEventListener.call(window, 'focus', state.eventHandlers.focus);
@@ -712,6 +730,8 @@
712
730
 
713
731
  if (state.eventHandlers) {
714
732
  _removeEventListener.call(document, 'fullscreenchange', state.eventHandlers.fullscreenchange);
733
+ _removeEventListener.call(document, 'webkitfullscreenchange', state.eventHandlers.fullscreenchange);
734
+ _removeEventListener.call(document, 'mozfullscreenchange', state.eventHandlers.fullscreenchange);
715
735
  _removeEventListener.call(document, 'visibilitychange', state.eventHandlers.visibilitychange);
716
736
  _removeEventListener.call(window, 'blur', state.eventHandlers.blur);
717
737
  _removeEventListener.call(window, 'focus', state.eventHandlers.focus);
@@ -724,7 +744,7 @@
724
744
  }
725
745
 
726
746
  function onFullscreenChange() {
727
- if (document.fullscreenElement) {
747
+ if (fullscreenElementOf(document)) {
728
748
  captureBaseline();
729
749
  // Open a brief window during which the next resize re-captures
730
750
  // baseline. Chrome reports a stale devicePixelRatio on the
@@ -802,7 +822,7 @@
802
822
  state.tamperHandle = _setInterval(tamperCheck, TAMPER_CHECK_MS);
803
823
  logDebug('tamper_poll.started', { interval_ms: TAMPER_CHECK_MS });
804
824
  }
805
- if (document.fullscreenElement) {
825
+ if (fullscreenElementOf(document)) {
806
826
  captureBaseline(function () { update('start'); });
807
827
  } else {
808
828
  update('start');
@@ -885,8 +905,11 @@
885
905
  }
886
906
 
887
907
  // ----- Entry trial ---------------------------------------------
888
- function createEntryTrial(opts = {}) {
889
- const message = opts.message || `
908
+ // Hoisted out of createEntryTrial (verbatim string move) so the frozen
909
+ // public API can expose it as `defaultEntryMessage` below — demo/demo.js
910
+ // renders this SAME string verbatim on its guard-entry step, so it can
911
+ // never drift out of sync with what a real participant actually sees.
912
+ const DEFAULT_ENTRY_MESSAGE = `
890
913
  <h2>Fullscreen mode required</h2>
891
914
  <p>To keep the experiment fair for everyone, we ask that this study be completed in fullscreen mode,
892
915
  with no browser sidebars (Gemini, Copilot, Edge sidebar, etc.) open, and with this tab focused.</p>
@@ -894,6 +917,9 @@
894
917
  <p>We care about collecting high-quality data, and these rules help ensure a fair experience for all participants.</p>
895
918
  <p>Please close any sidebars now, then click the button below to continue in fullscreen.</p>
896
919
  `;
920
+
921
+ function createEntryTrial(opts = {}) {
922
+ const message = opts.message || DEFAULT_ENTRY_MESSAGE;
897
923
  return {
898
924
  type: jsPsychHtmlButtonResponse,
899
925
  stimulus: message,
@@ -1028,6 +1054,9 @@
1028
1054
  createEntryTrial: function (opts) { return createEntryTrial(opts); },
1029
1055
  onViolation: function (handler) { return onViolation(handler); },
1030
1056
  getCurrentState: function () { return getCurrentState(); },
1057
+ // Added on the object literal BEFORE Object.freeze() below — a
1058
+ // post-freeze assignment would silently no-op.
1059
+ defaultEntryMessage: DEFAULT_ENTRY_MESSAGE,
1031
1060
  };
1032
1061
 
1033
1062
  Object.freeze(api);
@@ -1054,10 +1083,10 @@
1054
1083
  class GuardFrictionExtension {
1055
1084
  static info = {
1056
1085
  name: 'guard-friction',
1057
- // Hand-bumped on each release. The actual library version is whatever
1058
- // plugin-guard-friction.js exports at runtime; this number is for the
1059
- // jsPsych developer console only.
1060
- version: '0.4.0',
1086
+ // Hand-bumped on each release to track the package version
1087
+ // (package.json / src/shared/constants.js). Shown in the jsPsych developer
1088
+ // console only; the runtime library version is independent.
1089
+ version: '0.6.0',
1061
1090
  data: {}
1062
1091
  };
1063
1092
 
@@ -160,6 +160,8 @@
160
160
  // ----- Hidden form fields (catch full-DOM scrapes) -----
161
161
  const honeypot = _createElement.call(document, 'div');
162
162
  honeypot.id = 'fg-honeypot';
163
+ // Marks bait DOM for replay/DOM serializers to exclude ([data-ch-role]).
164
+ honeypot.setAttribute('data-ch-role', 'honeypot');
163
165
  Object.assign(honeypot.style, {
164
166
  position: 'absolute',
165
167
  width: '0',
@@ -199,6 +201,8 @@
199
201
  // doesn't change.
200
202
  const baitButton = _createElement.call(document, 'button');
201
203
  baitButton.id = 'fg-ai-bait-button';
204
+ // Marks bait DOM for replay/DOM serializers to exclude ([data-ch-role]).
205
+ baitButton.setAttribute('data-ch-role', 'honeypot');
202
206
  baitButton.type = 'button';
203
207
  baitButton.tabIndex = -1;
204
208
  baitButton.setAttribute('aria-label', BAIT_BUTTON_LABEL);
@@ -221,6 +225,8 @@
221
225
  const baitInput = _createElement.call(document, 'input');
222
226
  baitInput.type = 'text';
223
227
  baitInput.id = 'fg-ai-bait-input';
228
+ // Marks bait DOM for replay/DOM serializers to exclude ([data-ch-role]).
229
+ baitInput.setAttribute('data-ch-role', 'honeypot');
224
230
  baitInput.name = 'fg-ai-bait-input';
225
231
  baitInput.tabIndex = -1;
226
232
  baitInput.setAttribute('aria-label', BAIT_INPUT_LABEL);
@@ -285,12 +291,30 @@
285
291
  }
286
292
  }
287
293
 
294
+ function resetBaitFields() {
295
+ const aiUseEl = document.getElementById('fg-ai-use');
296
+ const aiReportEl = document.getElementById('fg-ai-report');
297
+ if (aiUseEl) aiUseEl.checked = false;
298
+ if (aiReportEl) aiReportEl.value = '';
299
+ }
300
+
288
301
  function init(opts) {
289
302
  opts = opts || {};
290
303
  if (opts.jsPsych) state.jsPsych = opts.jsPsych;
291
304
  if (typeof opts.debug === 'boolean') state.debugEnabled = opts.debug;
292
305
 
306
+ // Reset per-run forensic state. init() runs once per experiment, before
307
+ // any trial, so a second init() means a fresh run (jsPsych preview,
308
+ // restart, or two experiments sharing a tab) — it must NOT inherit the
309
+ // prior run's violation evidence or AI disclosures. injectHoneypotDOM()
310
+ // is idempotent (guards on state.injected), so on a re-init the old bait
311
+ // fields survive with their stale values; clear them explicitly.
312
+ state.violations = [];
313
+ state.currentViolation = null;
314
+ state.trialViolationStartIdx = 0;
315
+
293
316
  injectHoneypotDOM();
317
+ resetBaitFields();
294
318
 
295
319
  const friction = opts.friction || global.GuardFriction;
296
320
  if (friction && typeof friction.onViolation === 'function') {
@@ -418,7 +442,7 @@
418
442
  class GuardHoneypotExtension {
419
443
  static info = {
420
444
  name: 'guard-honeypot',
421
- version: '0.4.0',
445
+ version: '0.6.0',
422
446
  // Per-trial fields written by on_finish. Reflect violations for this
423
447
  // trial only; jsPsych spreads them onto each trial row (CSV columns).
424
448
  // Session totals are written as global properties by finalize().