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.
- package/CHANGELOG.md +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +43 -14
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
889
|
-
|
|
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
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
version: '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.
|
|
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().
|