Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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, scroll position (window and per-element), 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 the rendered DOM at the start of every trial, all DOM mutations within `#jspsych-content`, mouse, touch, keyboard, and clipboard events, scroll position (window and per-element), video and audio playback events, viewport changes, and every `Math.random()` output. The returned object is versioned (`schema_version: 1`); see [Session Recording Schema](./session-recording-schema.md) for the full format reference.

### 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
410 changes: 410 additions & 0 deletions docs/reference/session-recording-schema.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ nav:
- 'jsPsych.turk': 'reference/jspsych-turk.md'
- 'jsPsych.utils': 'reference/jspsych-utils.md'
- 'jsPsych.pluginAPI': 'reference/jspsych-pluginAPI.md'
- 'Session Recording Schema': 'reference/session-recording-schema.md'
- Plugins:
- 'List of Plugins': 'plugins/list-of-plugins.md'
- 'animation': 'plugins/animation.md'
Expand Down
10 changes: 9 additions & 1 deletion packages/jspsych/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname);
const baseConfig = require("@jspsych/config/jest").makePackageConfig(__dirname);

module.exports = {
...baseConfig,
// Polyfill Web Streams / Fetch / TextEncoder onto the jsdom global so
// code that uses `CompressionStream`, `Response`, etc. (e.g. the
// session recording's compressed-output path) is testable here.
setupFiles: [...(baseConfig.setupFiles ?? []), require.resolve("./tests/jsdom-polyfills.cjs")],
};
111 changes: 105 additions & 6 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 } 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,14 +155,22 @@ export class JsPsych {

this.experimentStartTime = new Date();

await this.timeline.run();
await Promise.resolve(this.options.on_finish(this.data.get()));
this.sessionRecorder?.start(this.getDisplayElement(), this.getDisplayContainerElement());

if (this.endMessage) {
this.getDisplayElement().innerHTML = this.endMessage;
}
// The recorder patches `Math.random` and attaches global listeners; we
// must always tear it down (and remove interaction listeners), even if
// the timeline run or the user-provided `on_finish` callback throws.
try {
await this.timeline.run();
await Promise.resolve(this.options.on_finish(this.data.get()));

this.data.removeInteractionListeners();
if (this.endMessage) {
this.getDisplayElement().innerHTML = this.endMessage;
}
} finally {
this.data.removeInteractionListeners();
this.sessionRecorder?.stop("finished");
}
}

async simulate(
Expand Down Expand Up @@ -196,12 +212,86 @@ 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();
}

/**
* Returns the session recording as a gzip-compressed `Blob` with MIME type
* `application/gzip`. Returns `undefined` when recording is not enabled.
* Typical recordings compress 8-15x — useful when persisting alongside
* trial data or uploading to a backend. Uses the browser's built-in
* `CompressionStream`, so no extra dependency is bundled.
*
* @example Upload to a backend:
* ```ts
* const blob = await jsPsych.getSessionRecordingCompressed();
* if (blob) {
* await fetch("/upload", { method: "POST", body: blob });
* }
* ```
*
* @example Offer the participant a download:
* ```ts
* const blob = await jsPsych.getSessionRecordingCompressed();
* if (blob) {
* const a = document.createElement("a");
* a.href = URL.createObjectURL(blob);
* a.download = "session.json.gz";
* a.click();
* URL.revokeObjectURL(a.href);
* }
* ```
*
* @example Stash in jsPsych's data record (base64-encoded):
* ```ts
* const blob = await jsPsych.getSessionRecordingCompressed();
* if (blob) {
* const buf = new Uint8Array(await blob.arrayBuffer());
* let binary = "";
* for (const byte of buf) binary += String.fromCharCode(byte);
* jsPsych.data.addProperties({ session_recording_b64: btoa(binary) });
* }
* ```
*/
async getSessionRecordingCompressed(): Promise<Blob | undefined> {
const recording = this.getSessionRecording();
if (!recording) return undefined;
// Drive the compression stream directly via its writer/reader so the
// implementation depends only on `TextEncoder`, `CompressionStream`,
// and `Blob` — all available in evergreen browsers (Chrome 80,
// Firefox 113, Safari 16.4) and in Node 18+.
const json = JSON.stringify(recording);
const cs = new CompressionStream("gzip");
const writer = cs.writable.getWriter();
// Don't await the writer; the reader loop below pulls chunks out as
// the writer pushes them in. Awaiting first would deadlock on
// backpressure for large recordings.
writer.write(new TextEncoder().encode(json));
writer.close();
const reader = cs.readable.getReader();
const chunks: Uint8Array[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return new Blob(chunks, { type: "application/gzip" });
}

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 +484,10 @@ export class JsPsych {

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

// apply the focus to the element containing the experiment.
Expand All @@ -402,6 +496,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 +510,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