Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
00b1e3d
feat(jspsych): add record_session option for high-fidelity replay cap…
claude Apr 29, 2026
5501e53
feat(jspsych): capture scroll events in session recording
claude Apr 29, 2026
d907630
fix(jspsych): preserve user-set Math.random; harden cleanup paths
claude Apr 29, 2026
9217f6a
refactor(jspsych): make SessionRecorder reusable across runs
claude Apr 29, 2026
d396102
refactor(jspsych): move rng_calls to session scope; drop unused idToNode
claude Apr 29, 2026
80a290a
refactor(jspsych): make DOM the single source of truth for replay
claude Apr 30, 2026
4e2f79a
test(jspsych): cover multi-trial, viewport, focus, media, and abort p…
claude Apr 30, 2026
6748043
Merge remote-tracking branch 'origin/main' into claude/add-hifi-data-…
claude Apr 30, 2026
45be441
ci: re-trigger preview-publish to verify PR comment posting
claude Apr 30, 2026
d360fcb
docs: add session recording schema reference
claude Apr 30, 2026
dc21625
fix(jspsych): capture document stylesheets so replay preserves CSS
claude May 1, 2026
2495e0a
feat(jspsych): track <head> stylesheet changes during replay capture
claude May 1, 2026
61e35b5
feat(jspsych): record form-state changes for survey replay fidelity
claude May 1, 2026
4b2445f
feat(jspsych): capture <canvas> pixel state for replay fidelity
claude May 1, 2026
7998445
feat(jspsych): widen initial_dom into a layout spine for replay fidelity
claude May 1, 2026
c7feb4a
feat(jspsych): canvas region-diff snapshots and draw-detection for re…
jodeleeuw May 3, 2026
3e94add
fix(jspsych): scope mouse/touch listeners to window so off-content ac…
jodeleeuw May 4, 2026
7d4df31
feat(jspsych): add getSessionRecordingCompressed() returning a gzip Blob
jodeleeuw May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/reference/jspsych.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The settings object can contain several parameters. None of the parameters are r
| override_safe_mode | boolean | Running a jsPsych experiment directly in a web browser (e.g., by double clicking on a local HTML file) will load the page using the `file://` protocol. Some features of jsPsych don't work with this protocol. By default, when jsPsych detects that it's running on a page loaded via the `file://` protocol, it runs in _safe mode_, which automatically disables features that don't work in this context. Specifically, the use of Web Audio is disabled (audio will be played using HTML5 audio instead, even if `use_webaudio` is `true`) and video preloading is disabled. The `override_safe_mode` parameter defaults to `false`, but you can set it to `true` to force these features to operate under the `file://` protocol. In order for this to work, you will need to disable web security (CORS) features in your browser - this is safe to do if you know what you are doing. Note that this parameter has no effect when you are running the experiment on a web server, because the page will be loaded via the `http://` or `https://` protocol. |
| case_sensitive_responses | boolean | If `true`, then jsPsych will make a distinction between uppercase and lowercase keys when evaluating keyboard responses, e.g. "A" (uppercase) will not be recognized as a valid response if the trial only accepts "a" (lowercase). If false, then jsPsych will not make a distinction between uppercase and lowercase keyboard responses, e.g. both "a" and "A" responses will be valid when the trial's key choice parameter is "a". Setting this parameter to false is useful if you want key responses to be treated the same way when CapsLock is turned on or the Shift key is held down. The default value is `false`. |
extensions | array | Array containing information about one or more jsPsych extensions that are used during the experiment. Each extension should be specified as an object with `type` (required), which is the name of the extension, and `params` (optional), which is an object containing any parameter-value pairs to be passed to the extension's `initialize` function. Default value is an empty array. |
| record_session | boolean | If `true`, jsPsych captures a high-fidelity recording of the session — DOM mutations within the display element, mouse/touch/keyboard/clipboard input, video and audio playback events, viewport changes (including pinch zoom), and every `Math.random()` output — sufficient to reconstruct a replay of what the participant saw and did. The recording is retrieved at the end of the experiment via `jsPsych.getSessionRecording()` and can be `JSON.stringify`'d and saved alongside the trial data. While enabled, `Math.random` is wrapped to log every call into the recording; this is reverted when the experiment ends. The default value is `false`. **Note:** text typed into form inputs (e.g. survey responses) is captured verbatim. Inform participants accordingly. |

### Return value

Expand Down Expand Up @@ -460,6 +461,39 @@ alert('You have completed approximately '+progress.percent_complete+'% of the ex
```


---

## jsPsych.getSessionRecording

```javascript
jsPsych.getSessionRecording()
```

### Parameters

None.

### Return value

Returns a `SessionRecording` object when the experiment was initialized with `record_session: true`, or `undefined` otherwise. The object is JSON-serializable.

### Description

Returns the high-fidelity session recording produced by the `record_session` option. The recording includes a snapshot of the trial parameters and rendered DOM at the start of every trial, all DOM mutations within `#jspsych-content`, mouse, touch, keyboard, and clipboard events, video and audio playback events, viewport changes, and every `Math.random()` output. The returned object contains the schema version (`schema_version: 1`); the on-disk format is the contract between recorder and any replayer.

### Example

```javascript
var jsPsych = initJsPsych({
record_session: true,
on_finish: function() {
var recording = jsPsych.getSessionRecording();
var blob = new Blob([JSON.stringify(recording)], { type: "application/json" });
// ...persist or upload `blob` alongside the trial data
}
});
```

---

## jsPsych.getStartTime
Expand Down
33 changes: 33 additions & 0 deletions packages/jspsych/src/JsPsych.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { JsPsychExtension } from "./modules/extensions";
import { PluginAPI, createJointPluginAPIObject } from "./modules/plugin-api";
import { JsPsychPlugin } from "./modules/plugins";
import * as randomization from "./modules/randomization";
import { SessionRecorder, SessionRecording, extractStimulusSource } from "./modules/recording";
import * as turk from "./modules/turk";
import * as utils from "./modules/utils";
import { ProgressBar } from "./ProgressBar";
Expand Down Expand Up @@ -66,6 +67,8 @@ export class JsPsych {

private extensionManager: ExtensionManager;

private sessionRecorder?: SessionRecorder;

constructor(options?) {
// override default options if user specifies an option
options = {
Expand All @@ -86,10 +89,15 @@ export class JsPsych {
override_safe_mode: false,
case_sensitive_responses: false,
extensions: [],
record_session: false,
...options,
};
this.options = options;

if (options.record_session) {
this.sessionRecorder = new SessionRecorder({ jspsychVersion: version });
}

autoBind(this); // so we can pass JsPsych methods as callbacks and `this` remains the JsPsych instance

// detect whether page is running in browser as a local file, and if so, disable web audio and
Expand Down Expand Up @@ -147,6 +155,8 @@ export class JsPsych {

this.experimentStartTime = new Date();

this.sessionRecorder?.start(this.getDisplayElement());

await this.timeline.run();
await Promise.resolve(this.options.on_finish(this.data.get()));

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run() starts/stops the session recorder, but the stop/unpatch cleanup won't run if timeline.run() or the user-provided on_finish callback throws/rejects. Since recording wraps Math.random and attaches global listeners, this can leak patched state beyond the experiment.

Consider wrapping the body of run() in a try { ... } finally { ... } that always calls this.sessionRecorder?.stop("finished") (or an appropriate reason) and removes interaction listeners, even on error.

Copilot uses AI. Check for mistakes.
Expand All @@ -155,6 +165,7 @@ export class JsPsych {
}

this.data.removeInteractionListeners();
this.sessionRecorder?.stop("finished");
}

async simulate(
Expand Down Expand Up @@ -196,12 +207,23 @@ export class JsPsych {
return this.displayContainerElement;
}

/**
* Returns the high-fidelity session recording produced when `initJsPsych` is
* called with `record_session: true`. Returns `undefined` when recording is
* not enabled. The recording is suitable for serialization (e.g. via
* `JSON.stringify`) and persistence alongside the trial data.
*/
getSessionRecording(): SessionRecording | undefined {
return this.sessionRecorder?.getRecording();
}

abortExperiment(endMessage?: string, data = {}) {
this.endMessage = endMessage;
this.timeline.abort();
this.pluginAPI.cancelAllKeyboardResponses();
this.pluginAPI.clearAllTimeouts();
this.finishTrial(data);
this.sessionRecorder?.stop("aborted");
}

abortCurrentTimeline() {
Expand Down Expand Up @@ -394,6 +416,12 @@ export class JsPsych {

private timelineDependencies: TimelineNodeDependencies = {
onTrialStart: (trial: Trial) => {
this.sessionRecorder?.onTrialStart({
trial_index: trial.index ?? -1,
plugin: trial.pluginClass?.["info"]?.name ?? "unknown",
trial_params: trial.trialObject,
stimulus_source: extractStimulusSource(trial.trialObject),
});
this.options.on_trial_start(trial.trialObject);

// apply the focus to the element containing the experiment.
Expand All @@ -402,6 +430,10 @@ export class JsPsych {
this.getDisplayElement().scrollTop = 0;
},

onTrialLoad: (_trial: Trial) => {
this.sessionRecorder?.onTrialLoad();
},

onTrialResultAvailable: (trial: Trial) => {
const result = trial.getResult();
if (result) {
Expand All @@ -412,6 +444,7 @@ export class JsPsych {

onTrialFinished: (trial: Trial) => {
const result = trial.getResult();
this.sessionRecorder?.onTrialFinish(result);
this.options.on_trial_finish(result);

if (result) {
Expand Down
Loading
Loading