diff --git a/.changeset/khaki-rice-retire.md b/.changeset/khaki-rice-retire.md new file mode 100644 index 00000000..6dc3bc4d --- /dev/null +++ b/.changeset/khaki-rice-retire.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-browser-check": major +--- + +Initial release of the browser-check plugin. The plugin can measure various features and properties of the participant's browser and optionally exclude participants from the study based on these features and properties. diff --git a/.changeset/new-llamas-remember.md b/.changeset/new-llamas-remember.md new file mode 100644 index 00000000..4a59e616 --- /dev/null +++ b/.changeset/new-llamas-remember.md @@ -0,0 +1,5 @@ +--- +"jspsych": minor +--- + +`jsPsych.endExperiment()` has a new, optional second parameter for saving data. Passing in an object of key-value pairs will store the pairs in the data for the final trial of the experiment. diff --git a/docs/demos/jspsych-browser-check-demo1.html b/docs/demos/jspsych-browser-check-demo1.html new file mode 100644 index 00000000..1cce6deb --- /dev/null +++ b/docs/demos/jspsych-browser-check-demo1.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/docs/demos/jspsych-browser-check-demo2.html b/docs/demos/jspsych-browser-check-demo2.html new file mode 100644 index 00000000..b41f004c --- /dev/null +++ b/docs/demos/jspsych-browser-check-demo2.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + diff --git a/docs/demos/jspsych-browser-check-demo3.html b/docs/demos/jspsych-browser-check-demo3.html new file mode 100644 index 00000000..675a4900 --- /dev/null +++ b/docs/demos/jspsych-browser-check-demo3.html @@ -0,0 +1,51 @@ + + + + + + + + + + + + diff --git a/docs/demos/jspsych-browser-check-demo4.html b/docs/demos/jspsych-browser-check-demo4.html new file mode 100644 index 00000000..2cc5c276 --- /dev/null +++ b/docs/demos/jspsych-browser-check-demo4.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + diff --git a/docs/overview/exclude-browser.md b/docs/overview/exclude-browser.md index 7526d169..349d4c1c 100644 --- a/docs/overview/exclude-browser.md +++ b/docs/overview/exclude-browser.md @@ -1,30 +1,12 @@ # Exclude Participants Based on Browser Features +*Changed in 7.1* -Online subjects will use many different kinds of browsers. Depending on the experiment, it may be important to specify a minimum feature set of the browser. jsPsych makes this straightforward. Simply specify certain exclusion criteria in the `initJsPsych` method call. If a subject's browser doesn't meet the criteria the experiment will not start and the subject will see a message explaining the problem. For size restrictions the subject will see a message that displays the current size of their browser window and the minimum size needed to start the experiment, giving the subject an opportunity to enlarge the browser window to continue. +Online subjects will use many different kinds of browsers. +Depending on the experiment, it may be important to specify a minimum feature set of the browser. -Current exclusion options: -* Minimum browser width & height -* Support for the WebAudio API +As of v7.1 of jsPsych, the recommended way to do this is using the [browser-check plugin](../plugins/browser-check.md). +This plugin can record many features of the subject's browser and exclude subjects who do not meet a defined set of inclusion criteria. +Please see the [browser-check plugin documentation](../plugins/browser-check.md) for more details. -## Examples - -#### Exclude browsers that are not at least 800x600 pixels - -```javascript -initJsPsych({ - exclusions: { - min_width: 800, - min_height: 600 - } -}); -``` - -#### Exclude browsers that do not have access to the WebAudio API - -```javascript -initJsPsych({ - exclusions: { - audio: true - } -}); -``` +The prior approach of using the `exclusions` parameter in `initJsPsych()` is deprecated and will be removed in `v8.0`. +You can find the documentation for it in the [7.0 docs](https://www.jspsych.org/7.0/overview/exclude-browser). diff --git a/docs/plugins/browser-check.md b/docs/plugins/browser-check.md new file mode 100644 index 00000000..49599cc1 --- /dev/null +++ b/docs/plugins/browser-check.md @@ -0,0 +1,142 @@ +# browser-check + +This plugin measures and records various features of the participant's browser and can end the experiment if defined inclusion criteria are not met. + +The plugin currently can record the following features: + +* The width and height of the browser window in pixels. +* The type of browser used (e.g., Chrome, Firefox, Edge, etc.) and the version number of the browser.* +* Whether the participant is using a mobile device.* +* The operating system.* +* Support for the WebAudio API. +* Support for the Fullscreen API, e.g., through the [fullscreen plugin](../plugins/fullscreen.md). +* The display refresh rate in frames per second. +* Whether the device has a webcam and microphone. Note that this only reveals whether a webcam/microphone exists. The participant still needs to grant permission in order for the experiment to use these devices. + +!!! warning + Features with an * are recorded by parsing the [user agent string](https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent). + This method is accurate most of the time, but is not guaranteed to be correct. + The plugin uses the [detect-browser package](https://github.com/DamonOehlman/detect-browser) to perform user agent parsing. + You can find a list of supported browsers and OSes in the [source file](https://github.com/DamonOehlman/detect-browser/blob/master/src/index.ts). + +The plugin begins by measuring the set of features requested. +An inclusion function is evaluated to see if the paricipant passes the inclusion criteria. +If they do, then the trial ends and the experiment continues. +If they do not, then the experiment ends immediately. +If a minimum width and/or minimum height is desired, the plugin will optionally display a message to participants whose browser windows are too small to give them an opportunity to make the window larger if possible. +See the examples below for more guidance. + +## Parameters + +In addition to the [parameters available in all plugins](../overview/plugins.md#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of *undefined* must be specified. Other parameters can be left unspecified if the default value is acceptable. + +| Parameter | Type | Default Value | Description | +| ------------------------------ | ---------------- | ------------- | ---------------------------------------- | +| features | array of strings | `["width", "height", "webaudio", "browser", "browser_version", "mobile", "os", "fullscreen", "vsync_rate", "webcam", "microphone"]` | The list of browser features to record. The default value includes all of the available options. | +| skip_features | array of strings | `[]` | Any features listed here will be skipped, even if they appear in `features`. Use this when you want to run most of the defaults. +| vsync_frame_count | int | 60 | The number of frames to sample when measuring the display refresh rate (`"vsync_rate"`). Increasing the number will potenially improve the stability of the estimate at the cost of increasing the amount of time the plugin takes during this test. On most devices, 60 frames takes about 1 second to measure. +| allow_window_resize | bool | true | Whether to allow the participant to resize the browser window if the window is smaller than `minimum_width` and/or `minimum_height`. If `false`, then the `minimum_width` and `minimum_height` parameters are ignored and you can validate the size in the `inclusion_function`. +| minimum_height | int | 0 | If `allow_window_resize` is `true`, then this is the minimum height of the window (in pixels) that must be met before continuing. +| minimum_width | int | 0 | If `allow_window_resize` is `true`, then this is the minimum width of the window (in pixels) that must be met before continuing. +| window_resize_message | string | see description | The message that will be displayed during the interactive resize when `allow_window_resize` is `true` and the window is too small. If the message contains HTML elements with the special IDs `browser-check-min-width`, `browser-check-min-height`, `browser-check-actual-height`, and/or `browser-check-actual-width`, then the contents of those elements will be dynamically updated to reflect the `minimum_width`, `minimum_height` and measured width and height of the browser. The default message is: `

Your browser window is too small to complete this experiment. Please maximize the size of your browser window. If your browser window is already maximized, you will not be able to complete this experiment.

The minimum window width is px.

Your current window width is px.

The minimum window height is px.

Your current window height is px.

`. +resize_fail_button_text | string | `"I cannot make the window any larger"` | During the interactive resize, a button with this text will be displayed below the `window_resize_message` for the participant to click if the window cannot meet the minimum size needed. When the button is clicked, the experiment will end and `exclusion_message` will be displayed. +inclusion_function | function | `() => { return true; }` | A function that evaluates to `true` if the browser meets all of the inclusion criteria for the experiment, and `false` otherwise. The first argument to the function will be an object containing key value pairs with the measured features of the browser. The keys will be the same as those listed in `features`. See example below. +exclusion_message | function | `() => { return

Your browser does not meet the requirements to participate in this experiment.

}` | A function that returns the message to display if `inclusion_function` evaluates to `false` or if the participant clicks on the resize fail button during the interactive resize. In order to allow customization of the message, the first argument to the function will be an object containing key value pairs with the measured features of the browser. The keys will be the same as those listed in `features`. See example below. + +## Data Generated + +In addition to the [default data collected by all plugins](../overview/plugins.md#data-collected-by-all-plugins), this plugin collects the following data for each trial. + +| Name | Type | Value | +| ------------ | ------- | ---------------------------------------- | +| width | int | The width of the browser window in pixels. If interactive resizing happens, this is the width *after* resizing. +| height | int | The height of the browser window in pixels. If interactive resizing happens, this is the height *after* resizing. +| browser | string | The browser used. +| browser_version | string | The version number of the browser. +| os | string | The operating system used. +| mobile | bool | Whether the browser is a mobile device. +| webaudio | bool | Whether the browser supports the WebAudio API. +| fullscreen | bool | Whether the browser supports the Fullscreen API. +| vsync_rate | number | An estimate of the refresh rate of the screen, in frames per second. +| webcam | bool | Whether there is a webcam device available. Note that the participant still must grant permission to access the device before it can be used. +| microphone | bool | Whether there is an audio input device available. Note that the participant still must grant permission to access the device before it can be used. + +Note that all of these values are only recorded when the corresponding key is included in the `features` parameter for the trial. + +## Examples + +???+ example "Recording all of the available features, no exclusions" + === "Code" + ```javascript + var trial = { + type: jsPsychBrowserCheck + }; + ``` + + === "Demo" +
+ +
+ + Open demo in new tab + +???+ example "Using the inclusion function to mandate the use of Chrome or Firefox as the browser" + === "Code" + ```javascript + var trial = { + type: jsPsychBrowserCheck, + inclusion_function: (data) => { + return ['chrome', 'firefox'].contains(data.browser); + }, + exclusion_message: `

You must use Chrome or Firefox to complete this experiment.

` + }; + ``` + + === "Demo" +
+ +
+ + Open demo in new tab + +???+ example "Setting a minimum window height & width, with the option to resize the window" + === "Code" + ```javascript + var trial = { + type: jsPsychBrowserCheck, + minimum_width: 1000, + minimum_height: 600 + }; + ``` + + === "Demo" +
+

This demo only works in a resizable window. Please open it in new tab +

+ + Open demo in new tab + +???+ example "Custom exclusion message based on measured features" + === "Code" + ```javascript + var trial = { + type: jsPsychBrowserCheck, + inclusion_function: (data) => { + return data.browser == 'chrome' && data.mobile === false + }, + exclusion_message: (data) => { + if(data.mobile){ + return '

You must use a desktop/laptop computer to participate in this experiment.

'; + } else if(data.browser !== 'chrome'){ + return '

You must use Chrome as your browser to complete this experiment.

' + } + } + }; + ``` + + === "Demo" +
+ +
+ + Open demo in new tab \ No newline at end of file diff --git a/docs/plugins/list-of-plugins.md b/docs/plugins/list-of-plugins.md index c46ffa72..43c8f7ce 100644 --- a/docs/plugins/list-of-plugins.md +++ b/docs/plugins/list-of-plugins.md @@ -12,6 +12,7 @@ Plugin | Description [audio‑button‑response](audio-button-response.md) | Play an audio file and allow the subject to respond by choosing a button to click. The button can be customized extensively, e.g., using images in place of standard buttons. [audio‑keyboard‑response](audio-keyboard-response.md) | Play an audio file and allow the subject to respond by pressing a key. [audio‑slider‑response](audio-slider-response.md) | Play an audio file and allow the subject to respond by moving a slider to indicate a value. +[browser‑check](browser-check.md) | Measures various features of the participant's browser and runs an inclusion check to see if the browser meets a custom set of criteria for running the study. [call‑function](call-function.md) | Executes an arbitrary function call. Doesn't display anything to the subject, and the subject is usually unaware that this plugin has even executed. It's useful for performing tasks at specified times in the experiment, such as saving data. [canvas‑button‑response](canvas-button-response.md) | Draw a stimulus on a [HTML canvas element](https://www.w3schools.com/html/html5_canvas.asp), and record a button click response. Useful for displaying dynamic, parametrically-defined graphics, and for controlling the positioning of multiple graphical elements (shapes, text, images). [canvas‑keyboard‑response](canvas-keyboard-response) | Draw a stimulus on a [HTML canvas element](https://www.w3schools.com/html/html5_canvas.asp), and record a key press response. Useful for displaying dynamic, parametrically-defined graphics, and for controlling the positioning of multiple graphical elements (shapes, text, images). diff --git a/docs/reference/jspsych.md b/docs/reference/jspsych.md index de55cb9c..ff293d12 100644 --- a/docs/reference/jspsych.md +++ b/docs/reference/jspsych.md @@ -24,7 +24,7 @@ The settings object can contain several parameters. None of the parameters are r | on_data_update | function | Function to execute every time data is stored using the `jsPsych.data.write` method. All plugins use this method to save data (via a call to `jsPsych.finishTrial`, so this function runs every time a plugin stores new data. | | on_interaction_data_update | function | Function to execute every time a new interaction event occurs. Interaction events include clicking on a different window (blur), returning to the experiment window (focus), entering full screen mode (fullscreenenter), and exiting full screen mode (fullscreenexit). | | on_close | function | Function to execute when the user leaves the page. Can be used, for example, to save data before the page is closed. | -| exclusions | object | Specifies restrictions on the browser the subject can use to complete the experiment. See list of options below. | +| exclusions | object | Specifies restrictions on the browser the subject can use to complete the experiment. See list of options below. *This feature is deprecated as of v7.1 and will be removed in v8.0. The [browser-check plugin](../plugins/browser-check.md) is an improved way to handle exclusions.* | | show_progress_bar | boolean | If `true`, then [a progress bar](../overview/progress-bar.md) is shown at the top of the page. Default is `false`. | | message_progress_bar | string | Message to display next to the progress bar. The default is 'Completion Progress'. | | auto_update_progress_bar | boolean | If true, then the progress bar at the top of the page will automatically update as every top-level timeline or trial is completed. | @@ -169,14 +169,15 @@ jsPsych.run([block, after_block]); ## jsPsych.endExperiment ```javascript -jsPsych.endExperiment(end_message) +jsPsych.endExperiment(end_message, data) ``` ### Parameters | Parameter | Type | Description | | ----------- | ------ | ---------------------------------------- | -| end_message | string | A message to display on the screen after the experiment is over. | +| end_message | string | A message to display on the screen after the experiment is over. Can include HTML formatting. | +| data | object | An optional object of key-value pairs to store as data in the final trial of the experiment. ### Return value diff --git a/examples/exclusions.html b/examples/exclusions.html deleted file mode 100644 index a3347bf2..00000000 --- a/examples/exclusions.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - diff --git a/examples/jspsych-browser-check.html b/examples/jspsych-browser-check.html new file mode 100644 index 00000000..729896c8 --- /dev/null +++ b/examples/jspsych-browser-check.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 14a9ffb5..e2bb334e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - 'audio-button-response': 'plugins/audio-button-response.md' - 'audio-keyboard-response': 'plugins/audio-keyboard-response.md' - 'audio-slider-response': 'plugins/audio-slider-response.md' + - 'browser-check': 'plugins/browser-check.md' - 'call-function': 'plugins/call-function.md' - 'canvas-button-response': 'plugins/canvas-button-response.md' - 'canvas-keyboard-response': 'plugins/canvas-keyboard-response.md' diff --git a/package-lock.json b/package-lock.json index 433dae02..efc0e613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2454,6 +2454,10 @@ "resolved": "packages/plugin-audio-slider-response", "link": true }, + "node_modules/@jspsych/plugin-browser-check": { + "resolved": "packages/plugin-browser-check", + "link": true + }, "node_modules/@jspsych/plugin-call-function": { "resolved": "packages/plugin-call-function", "link": true @@ -5182,6 +5186,11 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, + "node_modules/detect-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.2.1.tgz", + "integrity": "sha512-eAcRiEPTs7utXWPaAgu/OX1HRJpxW7xSHpw4LTDrGFaeWnJ37HRlqpUkKsDm0AoTbtrvHQhH+5U2Cd87EGhJTg==" + }, "node_modules/detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", @@ -14631,6 +14640,21 @@ "jspsych": ">=7.0.0" } }, + "packages/plugin-browser-check": { + "name": "@jspsych/plugin-browser-check", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "detect-browser": "^5.2.1" + }, + "devDependencies": { + "@jspsych/config": "^1.0.0", + "@jspsych/test-utils": "^1.0.0" + }, + "peerDependencies": { + "jspsych": ">=7.0.0" + } + }, "packages/plugin-call-function": { "name": "@jspsych/plugin-call-function", "version": "1.0.0", @@ -16981,6 +17005,14 @@ "@jspsych/test-utils": "^1.0.0" } }, + "@jspsych/plugin-browser-check": { + "version": "file:packages/plugin-browser-check", + "requires": { + "@jspsych/config": "^1.0.0", + "@jspsych/test-utils": "^1.0.0", + "detect-browser": "^5.2.1" + } + }, "@jspsych/plugin-call-function": { "version": "file:packages/plugin-call-function", "requires": { @@ -19356,6 +19388,11 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, + "detect-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.2.1.tgz", + "integrity": "sha512-eAcRiEPTs7utXWPaAgu/OX1HRJpxW7xSHpw4LTDrGFaeWnJ37HRlqpUkKsDm0AoTbtrvHQhH+5U2Cd87EGhJTg==" + }, "detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index a4e501ac..2c1af491 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -300,12 +300,12 @@ export class JsPsych { } } - endExperiment(end_message: string) { + endExperiment(end_message = "", data = {}) { this.timeline.end_message = end_message; this.timeline.end(); this.pluginAPI.cancelAllKeyboardResponses(); this.pluginAPI.clearAllTimeouts(); - this.finishTrial(); + this.finishTrial(data); } endCurrentTimeline() { @@ -734,6 +734,11 @@ export class JsPsych { } private async checkExclusions(exclusions) { + if (exclusions.min_width || exclusions.min_height || exclusions.audio) { + console.warn( + "The exclusions option in `initJsPsych()` is deprecated and will be removed in a future version. We recommend using the browser-check plugin instead. See https://www.jspsych.org/latest/plugins/browser-check/." + ); + } // MINIMUM SIZE if (exclusions.min_width || exclusions.min_height) { const mw = exclusions.min_width || 0; diff --git a/packages/plugin-browser-check/jest.config.cjs b/packages/plugin-browser-check/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/plugin-browser-check/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-browser-check/package.json b/packages/plugin-browser-check/package.json new file mode 100644 index 00000000..bfb42ccd --- /dev/null +++ b/packages/plugin-browser-check/package.json @@ -0,0 +1,46 @@ +{ + "name": "@jspsych/plugin-browser-check", + "version": "0.1.0", + "description": "jsPsych plugin for checking browser features", + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "files": [ + "src", + "dist" + ], + "source": "src/index.ts", + "scripts": { + "test": "jest", + "test:watch": "npm test -- --watch", + "tsc": "tsc", + "build": "rollup --config", + "build:watch": "npm run build -- --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jspsych/jsPsych.git", + "directory": "packages/plugin-html-keyboard-response" + }, + "author": "Josh de Leeuw", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jsPsych/issues" + }, + "homepage": "https://www.jspsych.org/latest/plugins/html-keyboard-response", + "peerDependencies": { + "jspsych": ">=7.0.0" + }, + "devDependencies": { + "@jspsych/config": "^1.0.0", + "@jspsych/test-utils": "^1.0.0" + }, + "dependencies": { + "detect-browser": "^5.2.1" + } +} diff --git a/packages/plugin-browser-check/rollup.config.mjs b/packages/plugin-browser-check/rollup.config.mjs new file mode 100644 index 00000000..4759045f --- /dev/null +++ b/packages/plugin-browser-check/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychBrowserCheck"); diff --git a/packages/plugin-browser-check/src/index.spec.ts b/packages/plugin-browser-check/src/index.spec.ts new file mode 100644 index 00000000..dd7e23aa --- /dev/null +++ b/packages/plugin-browser-check/src/index.spec.ts @@ -0,0 +1,213 @@ +import { clickTarget, pressKey, startTimeline } from "@jspsych/test-utils"; + +import browserCheck from "."; + +jest.useFakeTimers(); + +describe("browser-check", () => { + test("contains data on window size", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, getData } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + }, + ]); + + await expectFinished(); + + expect(getData().values()[0].width).not.toBeUndefined(); + expect(getData().values()[0].height).not.toBeUndefined(); + }); + + test("contains browser data from userAgent", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, getData } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + }, + ]); + + await expectFinished(); + + expect(getData().values()[0].browser).toBe("chrome"); + expect(getData().values()[0].browser_version).toBe("18.0.1025"); + }); + + test("contains OS data", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, getData } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + }, + ]); + + await expectFinished(); + + expect(getData().values()[0].os).toBe("Android OS"); + }); + + test("exclusion message displayed if inclusion_function is false", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, getHTML } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + inclusion_function: (data) => { + return false; + }, + }, + ]); + + await expectFinished(); + + expect(getHTML()).toMatch(browserCheck.info.parameters.exclusion_message.default()); + }); + + test("inclusion_function gets data from checks", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, getHTML } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + inclusion_function: (data) => { + expect(data.browser).toBe("chrome"); + return true; + }, + }, + ]); + + await expectFinished(); + }); + + test("resize message is displayed when allowed", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, getHTML, displayElement } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + minimum_width: 1200, + minimum_height: 1000, + }, + ]); + + expect(getHTML()).toMatch("1200"); + expect(getHTML()).toMatch("1000"); + + clickTarget(displayElement.querySelector("button")); + + jest.runAllTimers(); + + await expectFinished(); + }); + + test("can change button text on interactive resize", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, getHTML, displayElement } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + minimum_width: 1200, + minimum_height: 1000, + resize_fail_button_text: "foo", + }, + ]); + + expect(displayElement.querySelector("button").innerHTML).toMatch("foo"); + + clickTarget(displayElement.querySelector("button")); + + jest.runAllTimers(); + + await expectFinished(); + }); + + test("resizing to meet minimum vals will finish trial", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, expectRunning } = await startTimeline([ + { + type: browserCheck, + skip_features: ["vsync_rate"], + minimum_width: 1200, + minimum_height: 1000, + resize_fail_button_text: "foo", + }, + ]); + + await expectRunning(); + + // @ts-ignore jsdom window innerWidth is settable + window.innerWidth = 2000; + // @ts-ignore jsdom window innerHeight is settable + window.innerHeight = 2000; + + jest.runAllTimers(); + + await expectFinished(); + }); + + test("vsync rate", async () => { + jest + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19" + ); + + const { expectFinished, expectRunning, getData } = await startTimeline([ + { + type: browserCheck, + features: ["vsync_rate"], + }, + ]); + + // this will simulate the requestAnimationFrame() calls that are needed for vsync_rate. + // each one will fake execute 16ms after the previous. + jest.runAllTimers(); + + await expectFinished(); + + expect(getData().values()[0].vsync_rate).toBe(1000 / 16); + }); +}); diff --git a/packages/plugin-browser-check/src/index.ts b/packages/plugin-browser-check/src/index.ts new file mode 100644 index 00000000..e01dfb01 --- /dev/null +++ b/packages/plugin-browser-check/src/index.ts @@ -0,0 +1,355 @@ +import { detect } from "detect-browser"; +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +const info = { + name: "browser-check", + parameters: { + /** + * List of features to check and record in the data + */ + features: { + type: ParameterType.STRING, + array: true, + default: [ + "width", + "height", + "webaudio", + "browser", + "browser_version", + "mobile", + "os", + "fullscreen", + "vsync_rate", + "webcam", + "microphone", + ], + }, + /** + * Any features listed here will be skipped, even if they appear in `features`. Useful for + * when you want to run most of the defaults. + */ + skip_features: { + type: ParameterType.STRING, + array: true, + default: [], + }, + /** + * The number of animation frames to sample when calculating vsync_rate. + */ + vsync_frame_count: { + type: ParameterType.INT, + default: 60, + }, + /** + * If `true`, show a message when window size is too small to allow the user + * to adjust if their screen allows for it. + */ + allow_window_resize: { + type: ParameterType.BOOL, + default: true, + }, + /** + * When `allow_window_resize` is `true`, this is the minimum width (px) that the window + * needs to be before the experiment will continue. + */ + minimum_width: { + type: ParameterType.INT, + default: 0, + }, + /** + * When `allow_window_resize` is `true`, this is the minimum height (px) that the window + * needs to be before the experiment will continue. + */ + minimum_height: { + type: ParameterType.INT, + default: 0, + }, + /** + * Message to display during interactive window resizing. + */ + window_resize_message: { + type: ParameterType.HTML_STRING, + default: `

Your browser window is too small to complete this experiment. Please maximize the size of your browser window. + If your browser window is already maximized, you will not be able to complete this experiment.

+

The minimum window width is px.

+

Your current window width is px.

+

The minimum window height is px.

+

Your current window height is px.

`, + }, + /** + * During the interactive resize, a button with this text will be displayed below the + * `window_resize_message` for the participant to click if the window cannot meet the + * minimum size needed. When the button is clicked, the experiment will end and + * `exclusion_message` will be displayed. + */ + resize_fail_button_text: { + type: ParameterType.STRING, + default: "I cannot make the window any larger", + }, + /** + * A function that evaluates to `true` if the browser meets all of the inclusion criteria + * for the experiment, and `false` otherwise. The first argument to the function will be + * an object containing key value pairs with the measured features of the browser. The + * keys will be the same as those listed in `features`. + */ + inclusion_function: { + type: ParameterType.FUNCTION, + default: () => { + return true; + }, + }, + /** + * The message to display if `inclusion_function` returns `false` + */ + exclusion_message: { + type: ParameterType.FUNCTION, + default: () => { + return `

Your browser does not meet the requirements to participate in this experiment.

`; + }, + }, + }, +}; + +type Info = typeof info; + +/** + * **browser-check** + * + * jsPsych plugin for checking features of the browser and validating against a set of inclusion criteria. + * + * @author Josh de Leeuw + * @see {@link https://www.jspsych.org/plugins/jspsych-browser-check/ browser-check plugin documentation on jspsych.org} + */ +class BrowserCheckPlugin implements JsPsychPlugin { + static info = info; + private end_flag = false; + + constructor(private jsPsych: JsPsych) {} + + private delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + trial(display_element: HTMLElement, trial: TrialType) { + const featureCheckFunctionsMap = new Map any>( + Object.entries({ + width: () => { + return window.innerWidth; + }, + height: () => { + return window.innerHeight; + }, + webaudio: () => { + if ( + window.AudioContext || + // @ts-ignore because prefixed not in document type + window.webkitAudioContext || + // @ts-ignore because prefixed not in document type + window.mozAudioContext || + // @ts-ignore because prefixed not in document type + window.oAudioContext || + // @ts-ignore because prefixed not in document type + window.msAudioContext + ) { + return true; + } else { + return false; + } + }, + browser: () => { + return detect().name; + }, + browser_version: () => { + return detect().version; + }, + mobile: () => { + return /Mobi/i.test(window.navigator.userAgent); + }, + os: () => { + return detect().os; + }, + fullscreen: () => { + if ( + document.exitFullscreen || + // @ts-ignore because prefixed not in document type + document.webkitExitFullscreen || + // @ts-ignore because prefixed not in document type + document.msExitFullscreen + ) { + return true; + } else { + return false; + } + }, + vsync_rate: () => { + return new Promise((resolve) => { + let t0 = performance.now(); + let deltas = []; + let framesToRun = trial.vsync_frame_count; + const finish = () => { + let sum = 0; + for (const v of deltas) { + sum += v; + } + const frame_rate = 1000.0 / (sum / deltas.length); + const frame_rate_two_sig_dig = Math.round(frame_rate * 100) / 100; + resolve(frame_rate_two_sig_dig); + }; + const nextFrame = () => { + let t1 = performance.now(); + deltas.push(t1 - t0); + t0 = t1; + framesToRun--; + if (framesToRun > 0) { + requestAnimationFrame(nextFrame); + } else { + finish(); + } + }; + const start = () => { + t0 = performance.now(); + requestAnimationFrame(nextFrame); + }; + requestAnimationFrame(start); + }); + }, + webcam: () => { + return new Promise((resolve, reject) => { + if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) { + resolve(false); + } + navigator.mediaDevices.enumerateDevices().then((devices) => { + const webcams = devices.filter((d) => { + return d.kind == "videoinput"; + }); + if (webcams.length > 0) { + resolve(true); + } else { + resolve(false); + } + }); + }); + }, + microphone: () => { + return new Promise((resolve, reject) => { + if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) { + resolve(false); + } + navigator.mediaDevices.enumerateDevices().then((devices) => { + const microphones = devices.filter((d) => { + return d.kind == "audioinput"; + }); + if (microphones.length > 0) { + resolve(true); + } else { + resolve(false); + } + }); + }); + }, + }) + ); + + const feature_data = new Map(); + const feature_checks: Promise[] = []; + const features_to_check = trial.features.filter((x) => !trial.skip_features.includes(x)); + + for (const feature of features_to_check) { + // this allows for feature check functions to be sync or async + feature_checks.push(Promise.resolve(featureCheckFunctionsMap.get(feature)())); + } + + Promise.allSettled(feature_checks).then((results) => { + for (let i = 0; i < features_to_check.length; i++) { + if (results[i].status === "fulfilled") { + // @ts-expect-error because .value isn't recognized for some reason + feature_data.set(features_to_check[i], results[i].value); + } else { + feature_data.set(features_to_check[i], null); + } + } + inclusion_check(); + }); + + const inclusion_check = async () => { + await check_allow_resize(); + + if (!this.end_flag && trial.inclusion_function(Object.fromEntries(feature_data))) { + end_trial(); + } else { + end_experiment(); + } + }; + + const check_allow_resize = async () => { + const w = feature_data.get("width"); + const h = feature_data.get("height"); + + if ( + trial.allow_window_resize && + (w || h) && + (trial.minimum_width > 0 || trial.minimum_height > 0) + ) { + display_element.innerHTML = + trial.window_resize_message + + `

`; + + display_element + .querySelector("#browser-check-max-size-btn") + .addEventListener("click", () => { + display_element.innerHTML = ""; + this.end_flag = true; + }); + + const min_width_el = display_element.querySelector("#browser-check-min-width"); + const min_height_el = display_element.querySelector("#browser-check-min-height"); + const actual_height_el = display_element.querySelector("#browser-check-actual-height"); + const actual_width_el = display_element.querySelector("#browser-check-actual-width"); + + while ( + !this.end_flag && + (window.innerWidth < trial.minimum_width || window.innerHeight < trial.minimum_height) + ) { + if (min_width_el) { + min_width_el.innerHTML = trial.minimum_width.toString(); + } + + if (min_height_el) { + min_height_el.innerHTML = trial.minimum_height.toString(); + } + + if (actual_height_el) { + actual_height_el.innerHTML = window.innerHeight.toString(); + } + + if (actual_width_el) { + actual_width_el.innerHTML = window.innerWidth.toString(); + } + + await this.delay(100); + + feature_data.set("width", window.innerWidth); + feature_data.set("height", window.innerHeight); + } + } + }; + + const end_trial = () => { + display_element.innerHTML = ""; + + const trial_data = { ...Object.fromEntries(feature_data) }; + + this.jsPsych.finishTrial(trial_data); + }; + + var end_experiment = () => { + display_element.innerHTML = ""; + + const trial_data = { ...Object.fromEntries(feature_data) }; + + this.jsPsych.endExperiment(trial.exclusion_message(trial_data), trial_data); + }; + } +} + +export default BrowserCheckPlugin; diff --git a/packages/plugin-browser-check/tsconfig.json b/packages/plugin-browser-check/tsconfig.json new file mode 100644 index 00000000..588f0448 --- /dev/null +++ b/packages/plugin-browser-check/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.core.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}