diff --git a/__tests__/script-tags.test.js b/__tests__/script-tags.test.js new file mode 100644 index 0000000..a4db246 --- /dev/null +++ b/__tests__/script-tags.test.js @@ -0,0 +1,38 @@ +/** + * @jest-environment node + * + * The pinned datapipe-client version in the pasted `} + {EXTENSION_PIPE_SCRIPT} {extensionSnippet(expId)} @@ -106,7 +107,7 @@ export default function CodeHints({ expId }) { Use saveBase64Data to upload binary files (audio, video, images). This example saves audio from the html-audio-response plugin. - {``} + {EXTENSION_PIPE_SCRIPT} {` @@ -132,7 +133,7 @@ export default function CodeHints({ expId }) { Request the next condition assignment. This is async, so wrap your experiment in an async function. - {``} + {EXTENSION_PIPE_SCRIPT} {` @@ -173,12 +174,12 @@ export default function CodeHints({ expId }) { Send your data as a string with a unique filename. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` const result = await DataPipe.saveData({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: "UNIQUE_FILENAME.csv", data: dataAsString, }); @@ -201,13 +202,13 @@ export default function CodeHints({ expId }) { Send each trial as it happens, so a participant who closes the tab partway through does not take all of their data with them. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` const filename = "UNIQUE_FILENAME.csv"; const session = DataPipe.createSession({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: filename, }); @@ -215,18 +216,14 @@ export default function CodeHints({ expId }) { session.record(trialData); // ...when the experiment ends: - await session.flush(); const result = await DataPipe.saveData({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: filename, data: dataAsString, - sessionId: session.sessionId, + session: session, }); await session.close({ submitted: result.ok });`} - - Flush before reading sessionId: the session starts in the background, and until it has, the id is empty. Submitting without it leaves the staged copy unmatched, and it comes back as a duplicate .partial.json. - A participant who finishes produces one ordinary file. One who quits partway produces a separate file ending in .partial.json, holding the trials they completed. Partial sessions do not count toward your session limit. @@ -238,12 +235,12 @@ export default function CodeHints({ expId }) { Send binary data (audio, video, images) as a base64 string. DataPipe decodes it and uploads the file to your storage provider. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` const result = await DataPipe.saveBase64Data({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: "UNIQUE_FILENAME.webm", data: base64DataString, });`} @@ -256,13 +253,13 @@ export default function CodeHints({ expId }) { Request the next condition assignment, a number starting at 0. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` let condition; try { - condition = await DataPipe.getCondition({ experimentID: "${expId}" }); + condition = await DataPipe.getCondition({ experiment_id: "${expId}" }); } catch (error) { document.body.innerHTML = "

The experiment could not be started.

"; throw error; diff --git a/components/dashboard/script-tags.js b/components/dashboard/script-tags.js new file mode 100644 index 0000000..5e39fb2 --- /dev/null +++ b/components/dashboard/script-tags.js @@ -0,0 +1,22 @@ +// The `; +export const DATAPIPE_CLIENT_SCRIPT = ``; diff --git a/components/home/hero-snippets.js b/components/home/hero-snippets.js index f7a7fbb..8791612 100644 --- a/components/home/hero-snippets.js +++ b/components/home/hero-snippets.js @@ -51,7 +51,7 @@ export const snippets = [ { role: "fg", text: "await DataPipe." }, { role: "fn", text: "saveData" }, { role: "fg", text: "({\n" }, - { role: "fg", text: " experimentID: " }, + { role: "fg", text: " experiment_id: " }, { role: "string", text: '"your_id"' }, { role: "fg", text: ",\n" }, { role: "fg", text: " filename: " }, diff --git a/packages/client/.changeset/experiment-id-name.md b/packages/client/.changeset/experiment-id-name.md new file mode 100644 index 0000000..eb24094 --- /dev/null +++ b/packages/client/.changeset/experiment-id-name.md @@ -0,0 +1,7 @@ +--- +"datapipe-client": minor +--- + +Accept `experiment_id`, the name the jsPsych extension and plugin use for the experiment ID, so the same option is spelled the same way everywhere. + +`experimentID` still works and there are no plans to remove it. Give one or the other. If both are given and differ, `saveData`, `saveBase64Data` and `getCondition` throw, and a session starts inert with a console warning. Nothing changes on the wire. diff --git a/packages/client/.changeset/session-ready.md b/packages/client/.changeset/session-ready.md new file mode 100644 index 0000000..e22fc02 --- /dev/null +++ b/packages/client/.changeset/session-ready.md @@ -0,0 +1,7 @@ +--- +"datapipe-client": minor +--- + +Pass the session to `saveData` with `session` instead of flushing and reading `sessionId` yourself. `saveData` waits for the session to start and sends its id. + +Adds `session.ready()`, which resolves once the session has started (or failed to). It waits only for startup, not for staged writes, so a final submission from a background tab is no longer held up by a throttled flush. `sessionId` still works. diff --git a/packages/client/README.md b/packages/client/README.md index 589f17e..f2dd023 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -13,16 +13,18 @@ npm install datapipe-client Or in a plain HTML page, which exposes a `DataPipe` global: ```html - + ``` +Keep the version in the URL. Without one, unpkg serves the newest release, which could change a study that is already collecting data. + ## Sending data at the end ```js import { saveData } from "datapipe-client"; const result = await saveData({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01.csv", data: "rt,response\n204,1\n389,0", }); @@ -42,7 +44,7 @@ Staging each trial as it is produced means a participant who closes the tab at t import { createSession, saveData } from "datapipe-client"; const session = createSession({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01.csv", }); @@ -50,12 +52,11 @@ const session = createSession({ session.record(trialData); // ...at the end: -await session.flush(); const result = await saveData({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01.csv", data: allTrialsAsCSV, - sessionId: session.sessionId, + session, }); await session.close({ submitted: result.ok }); ``` @@ -63,7 +64,7 @@ await session.close({ submitted: result.ok }); Three things are worth knowing: - **`createSession()` returns immediately.** The request that starts the session is still in flight, and trials recorded before it lands are buffered and staged once it does. Use `await startSession(...)` instead if you would rather wait and check `session.enabled`. -- **Flush before reading `sessionId`.** `flush()` waits for the session to start, so until you have awaited it, `sessionId` may still be empty. Submitting without it leaves DataPipe unable to match your file to the staged copy, which it would then recover a second time. +- **Pass the session to `saveData`.** It tells DataPipe that this submission completes the staged copy, so the staged copy is discarded instead of being recovered as a second file. If you build the request yourself, `await session.ready()` and send `session.sessionId`. - **Tell `close()` what happened.** `{ submitted: true }` cancels the abandonment marker, so a completed session is never also reported as abandoned. `{ submitted: false }` marks it now, so the staged trials are recovered on DataPipe's normal sweep rather than waiting out the 24-hour expiry. Nothing about staging will break your experiment. If the session cannot be started, every method on it becomes a no-op and the data is still submitted at the end. @@ -75,7 +76,7 @@ import { getCondition } from "datapipe-client"; let condition; try { - condition = await getCondition({ experimentID: "YOUR_EXPERIMENT_ID" }); + condition = await getCondition({ experiment_id: "YOUR_EXPERIMENT_ID" }); } catch (error) { document.body.innerHTML = "

The experiment could not be started.

"; throw error; @@ -90,7 +91,7 @@ try { import { saveBase64Data } from "datapipe-client"; await saveBase64Data({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01-recording.webm", data: base64EncodedString, }); @@ -114,7 +115,7 @@ setBaseURL("https://datapipe-test.web.app"); | `getCondition(options)` | `Promise` | **yes** | | `setBaseURL(url)` / `getBaseURL()` | — | no | -`DataPipeSession` has `enabled`, `sessionId`, `record(data)`, `flush()`, and `close({ submitted })`. +`DataPipeSession` has `enabled`, `sessionId`, `ready()`, `record(data)`, `flush()`, and `close({ submitted })`. `SaveResult` is `{ ok: boolean, status: number, body: any }`. A `status` of `0` means the request never reached DataPipe. diff --git a/packages/client/package.json b/packages/client/package.json index 698c490..cda9ee4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -41,7 +41,7 @@ "test:watch": "vitest", "size": "node scripts/report-size.mjs", "changeset": "changeset", - "version-packages": "changeset version", + "version-packages": "changeset version && node scripts/sync-readme-pin.mjs", "release": "npm run build && changeset publish" }, "dependencies": { diff --git a/packages/client/scripts/sync-readme-pin.mjs b/packages/client/scripts/sync-readme-pin.mjs new file mode 100644 index 0000000..6bdb849 --- /dev/null +++ b/packages/client/scripts/sync-readme-pin.mjs @@ -0,0 +1,33 @@ +// Points the README's unpkg URLs at the version in package.json. +// +// Runs as part of `npm run version-packages`, after `changeset version` has +// bumped package.json, so the "Release datapipe-client" PR carries the new +// pin and npm publishes a README whose script tag loads the release it +// ships with. The DataPipe site needs no counterpart: its samples read the +// version from package.json directly (components/dashboard/script-tags.js). +// +// Fails, rather than doing nothing, if the README has no unpkg URL to pin. +// A silent no-op would ship a README pointing at the previous release. + +import { readFileSync, writeFileSync } from "node:fs"; + +const packageURL = new URL("../package.json", import.meta.url); +const readmeURL = new URL("../README.md", import.meta.url); + +const { version } = JSON.parse(readFileSync(packageURL, "utf8")); +const readme = readFileSync(readmeURL, "utf8"); + +// Matches the URL with or without a version, so an unpinned one gets pinned. +const pin = /unpkg\.com\/datapipe-client(@[^/"\s]+)?/g; +if (!pin.test(readme)) { + console.error("sync-readme-pin: README.md has no unpkg.com/datapipe-client URL to pin."); + process.exit(1); +} + +const updated = readme.replace(pin, `unpkg.com/datapipe-client@${version}`); +if (updated !== readme) { + writeFileSync(readmeURL, updated); + console.log(`sync-readme-pin: README.md now pins datapipe-client@${version}.`); +} else { + console.log(`sync-readme-pin: README.md already pins datapipe-client@${version}.`); +} diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index f07cb9b..4e12f02 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -1,9 +1,10 @@ // The three one-shot REST calls: saveData, saveBase64Data, getCondition. // Incremental upload (DataPipeSession) lives in session.ts and calls -// saveData too, via the caller's own code -- see the `sessionId` option. +// saveData too, via the caller's own code -- see the `session` option. -import { endpoint, isSuccessfulResult, sendRequest } from "./http.js"; -import { SaveResult } from "./types.js"; +import { endpoint, experimentIDFrom, isSuccessfulResult, sendRequest } from "./http.js"; +import type { DataPipeSession } from "./session.js"; +import { ExperimentIDOption, SaveResult } from "./types.js"; export { setBaseURL, getBaseURL } from "./http.js"; @@ -34,27 +35,39 @@ async function postJSON( * Save data to a researcher's storage provider via pipe.jspsych.org (or * another deployment). * - * @param options.experimentID The 12-character experiment ID. + * @param options.experiment_id The 12-character experiment ID. (`experimentID` + * is accepted too; see `ExperimentIDOption`.) * @param options.filename A unique filename to save the data to, including * its extension. If it already exists, no data will be saved. * @param options.data A string-based representation of the data (JSON, CSV, * or any other text-based format). - * @param options.sessionId Present only for a streamed session -- see - * `DataPipeSession`. Carries no data of its own; it tells DataPipe which - * staged copy this submission supersedes. + * @param options.session The streamed session this submission completes, if + * there is one. saveData waits for it to start and sends its id, which + * tells DataPipe which staged copy this submission supersedes. + * @param options.sessionId The same id, given directly. Use `session` + * instead unless you are managing the id yourself; if both are given, + * `session` wins. * @param options.baseURL Override the DataPipe deployment for this call. */ -export async function saveData(options: { - experimentID: string; - filename: string; - data: string; - sessionId?: string; - baseURL?: string; -}): Promise { - const { experimentID, filename, data, sessionId, baseURL } = options; +export async function saveData( + options: ExperimentIDOption & { + filename: string; + data: string; + session?: DataPipeSession; + sessionId?: string; + baseURL?: string; + } +): Promise { + const experimentID = experimentIDFrom(options); + const { filename, data, session, baseURL } = options; if (!experimentID || !filename || !data) { throw new Error("Missing required parameter(s)."); } + let sessionId = options.sessionId; + if (session) { + await session.ready(); + sessionId = session.sessionId; + } return postJSON( endpoint("data", baseURL), { @@ -71,19 +84,22 @@ export async function saveData(options: { * Save base64-encoded data (e.g. audio, images) to a researcher's storage * provider. The server decodes it to binary before storing it. * - * @param options.experimentID The 12-character experiment ID. + * @param options.experiment_id The 12-character experiment ID. (`experimentID` + * is accepted too; see `ExperimentIDOption`.) * @param options.filename A unique filename to save the data to, including * its extension. * @param options.data The data as a base64-encoded string. * @param options.baseURL Override the DataPipe deployment for this call. */ -export async function saveBase64Data(options: { - experimentID: string; - filename: string; - data: string; - baseURL?: string; -}): Promise { - const { experimentID, filename, data, baseURL } = options; +export async function saveBase64Data( + options: ExperimentIDOption & { + filename: string; + data: string; + baseURL?: string; + } +): Promise { + const experimentID = experimentIDFrom(options); + const { filename, data, baseURL } = options; if (!experimentID || !filename || !data) { throw new Error("Missing required parameter(s)."); } @@ -112,26 +128,27 @@ export async function saveBase64Data(options: { * ```js * let condition; * try { - * condition = await getCondition({ experimentID: "abc123" }); + * condition = await getCondition({ experiment_id: "abc123" }); * } catch (error) { * document.body.innerHTML = "

The experiment could not be started.

"; * throw error; * } * ``` * - * @param options.experimentID The 12-character experiment ID. + * @param options.experiment_id The 12-character experiment ID. (`experimentID` + * is accepted too; see `ExperimentIDOption`.) * @param options.baseURL Override the DataPipe deployment for this call. * @throws If the request cannot be made, DataPipe refuses it (condition * assignment switched off, experiment closed), or the response carries no * condition. */ -export async function getCondition(options: { - experimentID: string; - baseURL?: string; -}): Promise { - const { experimentID, baseURL } = options; +export async function getCondition( + options: ExperimentIDOption & { baseURL?: string } +): Promise { + const experimentID = experimentIDFrom(options); + const { baseURL } = options; if (!experimentID) { - throw new Error("datapipe: getCondition requires an experimentID."); + throw new Error("datapipe: getCondition requires an experiment_id."); } let response: Response; diff --git a/packages/client/src/http.ts b/packages/client/src/http.ts index 98a6e4d..42d42d3 100644 --- a/packages/client/src/http.ts +++ b/packages/client/src/http.ts @@ -49,6 +49,28 @@ export function normalizeBaseURL(url: string): string { return url.slice(0, end); } +/** + * The experiment ID from an options object, whichever name it came under + * (see `ExperimentIDOption`). Returns "" when neither is given, so each + * caller keeps its own answer to a missing ID. + * + * Throws when both are given and disagree. TypeScript already refuses both, + * but a plain-JavaScript caller gets no such check, and quietly picking one + * would send data to an experiment the researcher may not have meant. + */ +export function experimentIDFrom(options: { + experiment_id?: string; + experimentID?: string; +}): string { + const { experiment_id, experimentID } = options; + if (experiment_id && experimentID && experiment_id !== experimentID) { + throw new Error( + "datapipe: experiment_id and experimentID were both given and differ. Pass only experiment_id." + ); + } + return experiment_id || experimentID || ""; +} + export function endpoint(path: string, override?: string): string { return `${normalizeBaseURL(override || baseURL)}/api/${path}/`; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4fce2b1..1d12e82 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -12,4 +12,4 @@ export { saveData, saveBase64Data, getCondition, setBaseURL, getBaseURL } from "./api.js"; export { DataPipeSession, createSession, startSession } from "./session.js"; -export type { SessionOptions, SaveResult, SessionConfig } from "./types.js"; +export type { ExperimentIDOption, SessionOptions, SaveResult, SessionConfig } from "./types.js"; diff --git a/packages/client/src/session.ts b/packages/client/src/session.ts index 32507ad..dd1b67f 100644 --- a/packages/client/src/session.ts +++ b/packages/client/src/session.ts @@ -43,7 +43,7 @@ import { update, } from "firebase/database"; -import { endpoint } from "./http.js"; +import { endpoint, experimentIDFrom } from "./http.js"; import { SessionConfig, SessionOptions } from "./types.js"; // How many trials record() will hold onto before the session has finished @@ -76,7 +76,10 @@ export class DataPipeSession { } private _sessionId = ""; - /** The id to send with the final submission. Empty when never enabled. */ + /** + * The id to send with the final submission. Empty until the session has + * started (see `ready()`), and for good if it never does. + */ get sessionId(): string { return this._sessionId; } @@ -145,6 +148,19 @@ export class DataPipeSession { return this.startPromise; } + /** + * Resolves once the session has started, or failed to start. Never + * rejects. From then on `sessionId` is final: the session's id, or "" if + * it could not start. + * + * This waits only for the round trip to /api/session, not for any staged + * writes, so it is the thing to await before submitting. (`saveData` does + * it for you when given `session`.) + */ + async ready(): Promise { + await this.startPromise; + } + private async doStart( experimentID: string, endpointURL: string, @@ -557,6 +573,24 @@ export class DataPipeSession { } } +/** + * The experiment ID for a session, or "" if there is no usable one. + * + * `experimentIDFrom` throws when `experiment_id` and `experimentID` + * disagree, which is right for `saveData` but not here: starting a session + * never throws (see `startSession`). An empty ID turns streaming off in + * `doStart`, and a `saveData` call given the same options throws where + * the caller can see it. + */ +function sessionExperimentID(options: SessionOptions): string { + try { + return experimentIDFrom(options); + } catch (error) { + console.warn((error as Error).message); + return ""; + } +} + /** * Start an incremental upload session SYNCHRONOUSLY: the session object is * returned immediately, and the POST /api/session round trip runs in the @@ -580,7 +614,7 @@ export function createSession(options: SessionOptions): DataPipeSession { const session = new DataPipeSession(); // Fire-and-forget: errors are handled inside start() itself (see // doStart's catch block) and never surface here or reject anything. - void session.start(options.experimentID, endpoint("session", options.baseURL), { + void session.start(sessionExperimentID(options), endpoint("session", options.baseURL), { filename: options.filename, }); return session; @@ -602,7 +636,7 @@ export function createSession(options: SessionOptions): DataPipeSession { */ export async function startSession(options: SessionOptions): Promise { const session = new DataPipeSession(); - await session.start(options.experimentID, endpoint("session", options.baseURL), { + await session.start(sessionExperimentID(options), endpoint("session", options.baseURL), { filename: options.filename, }); return session; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 6ea8d8a..b63227b 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -2,10 +2,29 @@ // surface these describe, and docs/streaming-ingest-design.md (in the // DataPipe repository) for the staging-tier design these types are part of. +/** + * The experiment ID, under either name. + * + * `experiment_id` is the documented one, because it is what the jsPsych + * extension and the older jsPsych plugin call it, and a researcher moving + * between them should not have to remember a second spelling. `experimentID` + * was this library's only name in 0.1.0 and keeps working, since studies + * already running on an unpinned script tag pass it. Give one, not both. + */ +export type ExperimentIDOption = + | { + /** The 12-character experiment ID provided by pipe.jspsych.org. */ + experiment_id: string; + experimentID?: never; + } + | { + /** The same, under the name 0.1.0 used. Prefer `experiment_id`. */ + experimentID: string; + experiment_id?: never; + }; + /** Options for starting an incremental-upload session. */ -export interface SessionOptions { - /** The 12-character experiment ID provided by pipe.jspsych.org. */ - experimentID: string; +export type SessionOptions = ExperimentIDOption & { /** * The filename this participant will submit under, if it is already known. * @@ -17,7 +36,7 @@ export interface SessionOptions { filename?: string; /** Override the DataPipe deployment. Defaults to https://pipe.jspsych.org. */ baseURL?: string; -} +}; /** The outcome of a `saveData` / `saveBase64Data` call. */ export interface SaveResult { diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 0c81f98..d0a655e 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -124,6 +124,51 @@ describe("saveData", () => { expect(body.sessionId).toBe("SESSION123"); }); + it("waits for a session that has not started yet, then sends its id", async () => { + let started!: () => void; + const session = { + sessionId: "", + ready: () => + new Promise((resolve) => { + started = () => { + session.sessionId = "SESSION123"; + resolve(); + }; + }), + }; + const fetchMock = mockFetch(() => ({ message: "Success" })); + + const pending = saveData({ + experimentID: "EXP12345", + filename: "p01.csv", + data: "a,b\n1,2\n", + session: session as any, + }); + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + + started(); + await pending; + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body.sessionId).toBe("SESSION123"); + }); + + it("sends no sessionId for a session that could not start", async () => { + const session = { sessionId: "", ready: async () => {} }; + const fetchMock = mockFetch(() => ({ message: "Success" })); + + await saveData({ + experimentID: "EXP12345", + filename: "p01.csv", + data: "a,b\n1,2\n", + session: session as any, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body).not.toHaveProperty("sessionId"); + }); + it("throws on a missing required parameter", async () => { await expect(saveData({ experimentID: "", filename: "p01.csv", data: "x" })).rejects.toThrow(); }); @@ -250,3 +295,49 @@ describe("getCondition", () => { await expect(getCondition({ experimentID: "" })).rejects.toThrow(); }); }); + +// experiment_id is the documented name, matching the jsPsych extension and +// plugin. experimentID was the only name in 0.1.0, and studies running on an +// unpinned script tag still pass it, so it has to keep working. Either way the +// wire format is unchanged: the API has always read `experimentID`. +describe("the experiment ID's two names", () => { + const calls: Array<[string, (options: any) => Promise]> = [ + ["saveData", (id) => saveData({ ...id, filename: "p01.csv", data: "x" })], + ["saveBase64Data", (id) => saveBase64Data({ ...id, filename: "a.wav", data: "AAAA" })], + ["getCondition", (id) => getCondition(id)], + ]; + + it.each(calls)("%s sends experiment_id as experimentID", async (_name, call) => { + const fetchMock = mockFetch(() => ({ condition: 1 })); + + await call({ experiment_id: "EXP12345" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body.experimentID).toBe("EXP12345"); + expect(body).not.toHaveProperty("experiment_id"); + }); + + it.each(calls)("%s still accepts experimentID", async (_name, call) => { + const fetchMock = mockFetch(() => ({ condition: 1 })); + + await call({ experimentID: "EXP12345" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body.experimentID).toBe("EXP12345"); + }); + + it.each(calls)("%s accepts both when they agree", async (_name, call) => { + mockFetch(() => ({ condition: 1 })); + + await expect(call({ experiment_id: "EXP12345", experimentID: "EXP12345" })).resolves.not.toThrow(); + }); + + it.each(calls)("%s throws when they disagree, and sends nothing", async (_name, call) => { + const fetchMock = mockFetch(() => ({ condition: 1 })); + + await expect(call({ experiment_id: "EXP12345", experimentID: "OTHER" })).rejects.toThrow( + /experiment_id and experimentID/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/test/session.test.ts b/packages/client/test/session.test.ts index 8dfdaa6..ea6fce5 100644 --- a/packages/client/test/session.test.ts +++ b/packages/client/test/session.test.ts @@ -90,6 +90,33 @@ describe("startSession", () => { expect(session.enabled).toBe(false); }); + + it("accepts experiment_id, the name the jsPsych extension uses", async () => { + const fetchMock = mockFetch(() => SESSION_CONFIG); + + const session = await startSession({ experiment_id: "EXP12345", filename: "p01.csv" }); + + expect(JSON.parse(fetchMock.mock.calls[0][1]!.body as string)).toEqual({ + experimentID: "EXP12345", + filename: "p01.csv", + }); + expect(session.enabled).toBe(true); + }); + + it("returns an inert session, without throwing, when the two names disagree", async () => { + const fetchMock = mockFetch(() => SESSION_CONFIG); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const session = await startSession({ + experiment_id: "EXP12345", + experimentID: "OTHER", + } as any); + + expect(session.enabled).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect(warn.mock.calls[0][0]).toMatch(/experiment_id and experimentID/); + warn.mockRestore(); + }); }); describe("createSession (synchronous, pre-start buffering)", () => { @@ -183,6 +210,30 @@ describe("createSession (synchronous, pre-start buffering)", () => { expect(flushedTrials(0)).toEqual([["trials/0", '{"trial":0}']]); }); + it("ready() resolves once the session has started, without waiting for staged writes", async () => { + const release = deferredFetch(); + rtdb.update.mockImplementation(() => new Promise(() => {})); // a write that never lands + + const session = createSession({ experimentID: "EXP12345" }); + session.record({ trial: 0 }); + expect(session.sessionId).toBe(""); + + release(SESSION_CONFIG); + await session.ready(); + + expect(session.sessionId).toBe("SESSION123"); + }); + + it("ready() resolves, with an empty sessionId, when the start fails", async () => { + const release = deferredFetch(); + const session = createSession({ experimentID: "EXP12345" }); + + release({ ok: false, status: 400 }); + await session.ready(); + + expect(session.sessionId).toBe(""); + }); + it("a trial recorded after close() is requested is not staged", async () => { const release = deferredFetch(); diff --git a/pages/docs/experiments/sending-data.js b/pages/docs/experiments/sending-data.js index 13ecf51..ceda8ba 100644 --- a/pages/docs/experiments/sending-data.js +++ b/pages/docs/experiments/sending-data.js @@ -6,6 +6,7 @@ import DocsLayout from "../../../components/docs/DocsLayout"; import DocsSection from "../../../components/docs/DocsSection"; import CodeHints from "../../../components/dashboard/CodeHints"; import CodeBlock from "../../../components/CodeBlock"; +import { DATAPIPE_CLIENT_SCRIPT } from "../../../components/dashboard/script-tags"; // Prose link, per DESIGN.md §5: brandGreen.fg with a persistent underline, so // a link is never signalled by color alone. Local to this page for the same @@ -97,7 +98,7 @@ export default function SendingDataPage() { of the panel above switches every sample to it. - {``} + {DATAPIPE_CLIENT_SCRIPT} That gives you a DataPipe global. If you use a bundler,{" "} @@ -106,6 +107,13 @@ export default function SendingDataPage() { saveData, createSession,{" "} saveBase64Data, and getCondition. + + The URL names an exact version, and so does the extension's in + the jsPsych samples. Keep it pinned. Without a version, unpkg + serves the newest release, which could change a study that's + already collecting data. Move to a newer version when you're + ready to pilot with it. + Send whatever your experiment produces. The data string is stored byte for byte under the filename you give it. diff --git a/pages/docs/experiments/streaming.js b/pages/docs/experiments/streaming.js index bd076f9..a512ede 100644 --- a/pages/docs/experiments/streaming.js +++ b/pages/docs/experiments/streaming.js @@ -81,15 +81,10 @@ export default function SavingAsYouGoPage() { - One thing to get right in plain JavaScript:{" "} - - call flush() before you read sessionId. - {" "} - A session starts in the background, and until it has, the ID is an - empty string. If you submit without it, DataPipe can't match - your file to the staged copy, so it recovers that copy separately - and you end up with a stray .partial.json next to a - complete file. + At the end, pass the session to saveData as{" "} + session. That tells DataPipe the submission completes + the staged copy, so the staged copy is discarded rather than + recovered as a second file. The rest of the code, for jsPsych and plain JavaScript, and what