tessera-learn 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,12 +15,104 @@ const VERBS = {
15
15
  completed: 'http://adlnet.gov/expapi/verbs/completed',
16
16
  passed: 'http://adlnet.gov/expapi/verbs/passed',
17
17
  failed: 'http://adlnet.gov/expapi/verbs/failed',
18
- suspended: 'http://adlnet.gov/expapi/verbs/suspended',
19
18
  terminated: 'http://adlnet.gov/expapi/verbs/terminated',
19
+ // Intentionally absent: "satisfied" (LMS-only, §9.3.9) and
20
+ // "suspended" (not a cmi5-defined verb — §9.3 enumerates nine, none
21
+ // of them Suspended). The LMS infers Abandoned vs resume from
22
+ // registration state when Terminated lands without Completed.
20
23
  } as const;
21
24
 
22
25
  const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
23
26
 
27
+ const CMI5_MASTERYSCORE_EXT =
28
+ 'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
29
+
30
+ // cmi5 §9.6 — every cmi5 Defined Statement MUST carry the "cmi5" Category
31
+ // Activity in context.contextActivities.category, and "completed", "passed",
32
+ // "failed" MUST additionally carry the "moveOn" Category. Without these, an
33
+ // LRS will accept the statement as an arbitrary xAPI verb but won't roll it
34
+ // up into cmi5 lifecycle state — the LMS never sees the AU as completed.
35
+ const CMI5_CATEGORY_CMI5 =
36
+ 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
37
+ const CMI5_CATEGORY_MOVEON =
38
+ 'https://w3id.org/xapi/cmi5/context/categories/moveon';
39
+
40
+ export type CMI5MoveOn =
41
+ | 'Passed'
42
+ | 'Completed'
43
+ | 'CompletedAndPassed'
44
+ | 'CompletedOrPassed'
45
+ | 'NotApplicable';
46
+
47
+ const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
48
+ 'Passed',
49
+ 'Completed',
50
+ 'CompletedAndPassed',
51
+ 'CompletedOrPassed',
52
+ 'NotApplicable',
53
+ ]);
54
+
55
+ /** cmi5 §10.2.2 — launch mode dictates which Defined Statements the AU may emit. */
56
+ export type CMI5LaunchMode = 'Normal' | 'Browse' | 'Review';
57
+ const VALID_LAUNCH_MODE: ReadonlySet<CMI5LaunchMode> = new Set([
58
+ 'Normal',
59
+ 'Browse',
60
+ 'Review',
61
+ ]);
62
+
63
+ /** State doc id (cmi5 §10) the LMS pre-populates with launch metadata. */
64
+ const LMS_LAUNCH_DATA_STATE_ID = 'LMS.LaunchData';
65
+
66
+ /** Agent Profile id (cmi5 §11) where the LMS stores learner preferences. */
67
+ const CMI5_LEARNER_PREFS_PROFILE_ID = 'cmi5LearnerPreferences';
68
+
69
+ /** xAPI cmi5 sessionid context extension IRI (cmi5 §9.6.3.1). */
70
+ const CMI5_SESSIONID_EXT_IRI =
71
+ 'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
72
+
73
+ /** cmi5 §10 `LMS.LaunchData` document. `contextTemplate` is the base context for every Defined Statement (§9.6.2). */
74
+ interface CMI5LaunchData {
75
+ contextTemplate?: {
76
+ contextActivities?: {
77
+ category?: Array<{ id: string; objectType?: string }>;
78
+ grouping?: Array<{ id: string }>;
79
+ [k: string]: unknown;
80
+ };
81
+ extensions?: Record<string, unknown>;
82
+ [k: string]: unknown;
83
+ };
84
+ launchMode?: CMI5LaunchMode;
85
+ launchMethod?: 'OwnWindow' | 'AnyWindow';
86
+ launchParameters?: string;
87
+ returnURL?: string;
88
+ masteryScore?: number;
89
+ moveOn?: CMI5MoveOn;
90
+ entitlementKey?: Record<string, string>;
91
+ [k: string]: unknown;
92
+ }
93
+
94
+ /** cmi5 §11.1 Learner Preferences Agent Profile document. */
95
+ interface CMI5LearnerPreferences {
96
+ languagePreference?: string;
97
+ audioPreference?: 'on' | 'off';
98
+ [k: string]: unknown;
99
+ }
100
+
101
+ /** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
102
+ function warnOnLRSReject(
103
+ verbName: string
104
+ ): (res: { destinations?: Array<{ ok?: boolean; status?: number; error?: Error }> }) => void {
105
+ return (res) => {
106
+ const dest = res.destinations?.[0];
107
+ if (dest && !dest.ok) {
108
+ console.warn(
109
+ `Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
110
+ dest.error
111
+ );
112
+ }
113
+ };
114
+ }
115
+
24
116
  /**
25
117
  * CMI5 persistence adapter using xAPI.
26
118
  *
@@ -48,10 +140,28 @@ export class CMI5Adapter implements PersistenceAdapter {
48
140
  #durationSeconds = 0;
49
141
  #state: SavedState | null = null;
50
142
  #completedSent = false;
51
- #completionStatus: 'incomplete' | 'complete' = 'incomplete';
52
143
  #successSent = false;
53
144
  #terminated = false;
54
145
 
146
+ // cmi5 §8 launch params. masteryScore (when present) overrides the
147
+ // course's manifest passingScore for this launch — the LMS is the
148
+ // authority. moveOn drives the optional Satisfied statement (§9.5.3).
149
+ #masteryScore: number | null = null;
150
+ #moveOn: CMI5MoveOn = 'NotApplicable';
151
+
152
+ // cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
153
+ // (§9.6.2) — Publisher Activity and session id live there, and strict
154
+ // LRSes validate every Defined Statement against it.
155
+ #launchData: CMI5LaunchData | null = null;
156
+ /** cmi5 §10.2.2 — Browse/Review forbid every Defined Statement except Initialized/Terminated. */
157
+ #launchMode: CMI5LaunchMode = 'Normal';
158
+ /** cmi5 §10.2.6 — AU redirects here on `exit()`. */
159
+ #returnURL: string | undefined;
160
+ /** cmi5 §10.2.3 — opaque per-launch content config string. */
161
+ #launchParameters: string | undefined;
162
+ /** cmi5 §11.1 Learner Preferences. */
163
+ #learnerPreferences: CMI5LearnerPreferences | null = null;
164
+
55
165
  async init(): Promise<void> {
56
166
  const params = new URLSearchParams(window.location.search);
57
167
  const fetchUrl = params.get('fetch');
@@ -63,6 +173,29 @@ export class CMI5Adapter implements PersistenceAdapter {
63
173
  this.#registration = reg ? reg : undefined;
64
174
  this.#activityId = params.get('activityId') || '';
65
175
 
176
+ const rawMastery = params.get('masteryScore');
177
+ if (rawMastery !== null && rawMastery !== '') {
178
+ const m = Number(rawMastery);
179
+ if (Number.isFinite(m) && m >= 0 && m <= 1) {
180
+ this.#masteryScore = m;
181
+ } else {
182
+ console.warn(
183
+ `Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`
184
+ );
185
+ }
186
+ }
187
+
188
+ const rawMoveOn = params.get('moveOn');
189
+ if (rawMoveOn !== null && rawMoveOn !== '') {
190
+ if (VALID_MOVE_ON.has(rawMoveOn as CMI5MoveOn)) {
191
+ this.#moveOn = rawMoveOn as CMI5MoveOn;
192
+ } else {
193
+ console.warn(
194
+ `Tessera cmi5: launch parameter 'moveOn' is not a recognized value (got "${rawMoveOn}"); defaulting to NotApplicable.`
195
+ );
196
+ }
197
+ }
198
+
66
199
  // Malformed actor JSON is a launch-time failure: an empty {} actor
67
200
  // would fail every Identified-Agent check downstream and produce
68
201
  // confusing 400s on every send. Fail loud here instead.
@@ -100,27 +233,105 @@ export class CMI5Adapter implements PersistenceAdapter {
100
233
  `Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
101
234
  );
102
235
  }
103
- const text = await resp.text();
104
- // The fetch URL returns the token, possibly with "auth-token=" prefix
105
- // (cmi5 §6.2). The credential itself is the value used as the
106
- // "Basic" Authorization header NOT a Bearer token.
107
- this.#authToken = text.replace(/^auth-token=/, '').trim();
236
+ const text = (await resp.text()).trim();
237
+ // cmi5 §11.2 response is `{"auth-token": "..."}`. Some
238
+ // non-conformant LMSes return bare text with an `auth-token=`
239
+ // prefix, so we fall back to that. The token value is the literal
240
+ // Basic credential (already base64); we don't re-encode.
241
+ let token = '';
242
+ if (text.startsWith('{')) {
243
+ try {
244
+ const parsed = JSON.parse(text);
245
+ if (parsed && typeof parsed['auth-token'] === 'string') {
246
+ token = parsed['auth-token'].trim();
247
+ }
248
+ } catch {
249
+ // fall through to legacy parsing
250
+ }
251
+ }
252
+ if (!token) {
253
+ token = text.replace(/^auth-token=/, '').trim();
254
+ }
255
+ this.#authToken = token;
108
256
  if (!this.#authToken) {
109
257
  throw new Error(
110
- 'Tessera cmi5: fetch token request returned an empty body. Expected an "auth-token=..." or bare token.'
258
+ 'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.'
111
259
  );
112
260
  }
113
261
 
262
+ // cmi5 §10 — LaunchData is the only spec-defined channel for the
263
+ // session id (§9.6.3.1) and Publisher Activity (§9.6.2.3) the LRS
264
+ // validates against, plus launchMode/returnURL/launchParameters/
265
+ // masteryScore/moveOn (§10.2). LaunchData values override the URL
266
+ // masteryScore parsed earlier (§10.2.4 makes it authoritative).
267
+ this.#launchData = await this.#fetchLaunchData();
268
+ const tmpl = this.#launchData?.contextTemplate ?? {};
269
+ let sessionId: string | undefined;
270
+ const launchSession = (tmpl.extensions ?? {})[CMI5_SESSIONID_EXT_IRI];
271
+ if (typeof launchSession === 'string' && launchSession.trim()) {
272
+ sessionId = launchSession.trim();
273
+ }
274
+ if (this.#launchData) {
275
+ if (
276
+ typeof this.#launchData.launchMode === 'string' &&
277
+ VALID_LAUNCH_MODE.has(this.#launchData.launchMode)
278
+ ) {
279
+ this.#launchMode = this.#launchData.launchMode;
280
+ }
281
+ if (
282
+ typeof this.#launchData.returnURL === 'string' &&
283
+ this.#launchData.returnURL
284
+ ) {
285
+ this.#returnURL = this.#launchData.returnURL;
286
+ }
287
+ if (typeof this.#launchData.launchParameters === 'string') {
288
+ this.#launchParameters = this.#launchData.launchParameters;
289
+ }
290
+ if (
291
+ typeof this.#launchData.masteryScore === 'number' &&
292
+ Number.isFinite(this.#launchData.masteryScore) &&
293
+ this.#launchData.masteryScore >= 0 &&
294
+ this.#launchData.masteryScore <= 1
295
+ ) {
296
+ this.#masteryScore = this.#launchData.masteryScore;
297
+ }
298
+ if (
299
+ typeof this.#launchData.moveOn === 'string' &&
300
+ VALID_MOVE_ON.has(this.#launchData.moveOn)
301
+ ) {
302
+ this.#moveOn = this.#launchData.moveOn;
303
+ }
304
+ }
305
+
306
+ // cmi5 §11 — fetch the Agent Profile BEFORE Initialized. Strict
307
+ // LRSes track the GET and reject Initialized otherwise. A 404 here
308
+ // is legitimate (no prefs set); the GET itself is what's required.
309
+ this.#learnerPreferences = await this.#fetchLearnerPreferences();
310
+
114
311
  this.#publisher = new XAPIPublisher({
115
312
  endpoint: this.#endpoint,
116
313
  auth: this.#authToken,
117
314
  actor: this.#actor,
118
315
  activityId: this.#activityId,
119
316
  registration: this.#registration,
317
+ sessionId,
120
318
  cmi5Mode: true,
121
319
  });
122
320
  await this.#publisher.init();
123
321
 
322
+ // cmi5 §9.3.2 — queue Initialized before the resume State GET so a
323
+ // slow LRS can't push it past the spec's "reasonable period". The
324
+ // publisher queue keeps it ordered before any later Defined Statement.
325
+ this.#publisher
326
+ .sendStatement({
327
+ verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
328
+ context: this.#cmi5Context(),
329
+ })
330
+ .then(warnOnLRSReject('Initialized'))
331
+ .catch((err) => {
332
+ console.warn('Tessera cmi5: failed to send Initialized statement', err);
333
+ });
334
+
124
335
  // Retrieve saved state from xAPI State API. The State API is a different
125
336
  // URL than statements/, so it doesn't go through the publisher's send
126
337
  // path — but we still use the same auth/headers.
@@ -129,22 +340,17 @@ export class CMI5Adapter implements PersistenceAdapter {
129
340
  const resp = await this.#xapiFetch(stateUrl, { method: 'GET' });
130
341
  if (resp.ok) {
131
342
  this.#state = await resp.json();
343
+ } else if (resp.status !== 404) {
344
+ console.warn(
345
+ `Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`
346
+ );
132
347
  }
133
- } catch {
348
+ } catch (err) {
349
+ console.warn(
350
+ `Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`
351
+ );
134
352
  this.#state = null;
135
353
  }
136
-
137
- // Send Initialized statement (queued through publisher). Log failures
138
- // here too — the publisher's per-destination outcome covers transport
139
- // errors but won't surface to console; lifecycle statements are rare
140
- // enough that an explicit warning helps production triage.
141
- await this.#publisher
142
- .sendStatement({
143
- verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
144
- })
145
- .catch((err) => {
146
- console.warn('Tessera cmi5: failed to send Initialized statement', err);
147
- });
148
354
  }
149
355
 
150
356
  /**
@@ -156,6 +362,41 @@ export class CMI5Adapter implements PersistenceAdapter {
156
362
  return this.#publisher;
157
363
  }
158
364
 
365
+ /**
366
+ * LMS-supplied masteryScore from the cmi5 launch URL (a decimal in
367
+ * [0, 1]), or null when omitted. When present, the runtime should treat
368
+ * it as the authoritative pass threshold for this session, overriding
369
+ * `course.config.js scoring.passingScore`.
370
+ */
371
+ getMasteryScore(): number | null {
372
+ return this.#masteryScore;
373
+ }
374
+
375
+ /** LMS-supplied moveOn criterion (defaults to "NotApplicable"). */
376
+ getMoveOn(): CMI5MoveOn {
377
+ return this.#moveOn;
378
+ }
379
+
380
+ /** cmi5 §10.2.2 — "Normal" is the only mode where progress-bearing Defined Statements are permitted. */
381
+ getLaunchMode(): CMI5LaunchMode {
382
+ return this.#launchMode;
383
+ }
384
+
385
+ /** cmi5 §10.2.6 — URL the AU navigates to on `exit()`. Returns undefined when the LMS didn't supply one. */
386
+ getReturnURL(): string | undefined {
387
+ return this.#returnURL;
388
+ }
389
+
390
+ /** cmi5 §10.2.3 — opaque per-launch content-config string. */
391
+ getLaunchParameters(): string | undefined {
392
+ return this.#launchParameters;
393
+ }
394
+
395
+ /** cmi5 §11.1 Learner Preferences. Null when the LMS didn't publish one. */
396
+ getLearnerPreferences(): CMI5LearnerPreferences | null {
397
+ return this.#learnerPreferences;
398
+ }
399
+
159
400
  getState(): SavedState | null {
160
401
  return this.#state;
161
402
  }
@@ -167,11 +408,16 @@ export class CMI5Adapter implements PersistenceAdapter {
167
408
  // Terminated. We can't use sendStatement here (different URL/verb).
168
409
  this.#publisher.chainTask(async () => {
169
410
  try {
170
- await this.#xapiFetch(this.#buildStateUrl(), {
411
+ const resp = await this.#xapiFetch(this.#buildStateUrl(), {
171
412
  method: 'PUT',
172
413
  headers: { 'Content-Type': 'application/json' },
173
414
  body: JSON.stringify(state),
174
415
  });
416
+ if (!resp.ok) {
417
+ console.warn(
418
+ `Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`
419
+ );
420
+ }
175
421
  } catch (err) {
176
422
  console.warn('Tessera: Failed to save CMI5 state', err);
177
423
  }
@@ -179,25 +425,32 @@ export class CMI5Adapter implements PersistenceAdapter {
179
425
  }
180
426
 
181
427
  setScore(score: number): void {
182
- this.#score = score;
428
+ // Clamped to [0, 100] so the /100 division yields a spec-legal
429
+ // scaled value in [0, 1] (xAPI).
430
+ if (!Number.isFinite(score)) {
431
+ this.#score = null;
432
+ return;
433
+ }
434
+ this.#score = Math.max(0, Math.min(100, score));
183
435
  }
184
436
 
185
437
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
186
- this.#completionStatus = status;
187
438
  if (status !== 'complete' || this.#completedSent || !this.#publisher) return;
439
+ // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
440
+ if (this.#launchMode !== 'Normal') return;
188
441
  this.#completedSent = true;
442
+ // cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
189
443
  const result: Record<string, unknown> = {
190
444
  completion: true,
191
445
  duration: formatISO8601Duration(this.#durationSeconds),
192
446
  };
193
- if (this.#score !== null) {
194
- result.score = { scaled: this.#score / 100 };
195
- }
196
447
  this.#publisher
197
448
  .sendStatement({
198
449
  verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
199
450
  result,
451
+ context: this.#cmi5Context({ moveOn: true }),
200
452
  })
453
+ .then(warnOnLRSReject('Completed'))
201
454
  .catch((err) => {
202
455
  console.warn('Tessera cmi5: failed to send Completed statement', err);
203
456
  });
@@ -205,6 +458,8 @@ export class CMI5Adapter implements PersistenceAdapter {
205
458
 
206
459
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
207
460
  if (status === 'unknown' || this.#successSent || !this.#publisher) return;
461
+ // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
462
+ if (this.#launchMode !== 'Normal') return;
208
463
  this.#successSent = true;
209
464
 
210
465
  const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
@@ -214,13 +469,35 @@ export class CMI5Adapter implements PersistenceAdapter {
214
469
  duration: formatISO8601Duration(this.#durationSeconds),
215
470
  };
216
471
  if (this.#score !== null) {
217
- result.score = { scaled: this.#score / 100 };
472
+ const scaled = this.#score / 100;
473
+ // cmi5 §9.3.4 / §9.3.5 — Passed-with-score requires scaled >=
474
+ // masteryScore; Failed-with-score requires scaled < masteryScore.
475
+ // The author asserted the verb, so on contradiction we keep the
476
+ // verb and drop the score (and warn).
477
+ if (this.#masteryScore !== null) {
478
+ const violatesPassed = status === 'passed' && scaled < this.#masteryScore;
479
+ const violatesFailed = status === 'failed' && scaled >= this.#masteryScore;
480
+ if (violatesPassed || violatesFailed) {
481
+ console.warn(
482
+ `Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
483
+ `${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
484
+ `per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
485
+ `Statement will be sent without a score.`
486
+ );
487
+ } else {
488
+ result.score = { scaled };
489
+ }
490
+ } else {
491
+ result.score = { scaled };
492
+ }
218
493
  }
219
494
  this.#publisher
220
495
  .sendStatement({
221
496
  verb: { id: verb, display: { 'en-US': verbName } },
222
497
  result,
498
+ context: this.#cmi5Context({ moveOn: true, mastery: true }),
223
499
  })
500
+ .then(warnOnLRSReject(verbName === 'passed' ? 'Passed' : 'Failed'))
224
501
  .catch((err) => {
225
502
  console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
226
503
  });
@@ -264,7 +541,10 @@ export class CMI5Adapter implements PersistenceAdapter {
264
541
  },
265
542
  result,
266
543
  })
267
- .catch(() => {});
544
+ .then(warnOnLRSReject('Answered'))
545
+ .catch((err) => {
546
+ console.warn('Tessera cmi5: failed to send Answered statement', err);
547
+ });
268
548
  }
269
549
 
270
550
  commit(): void {
@@ -280,37 +560,102 @@ export class CMI5Adapter implements PersistenceAdapter {
280
560
  // must be the last statement of the cmi5 session (§9.3.6).
281
561
  this.#publisher.markUnloading();
282
562
  const duration = formatISO8601Duration(this.#durationSeconds);
283
- // cmi5 §10.1: when the AU exits without Completed, send Suspended
284
- // first so the LMS distinguishes a deliberate pause from abandonment.
285
- if (!this.#completedSent && this.#completionStatus !== 'complete') {
286
- this.#publisher
287
- .sendStatement({
288
- verb: { id: VERBS.suspended, display: { 'en-US': 'suspended' } },
289
- result: { duration },
290
- })
291
- .catch((err) => {
292
- console.warn('Tessera cmi5: failed to send Suspended statement', err);
293
- });
294
- }
295
- // cmi5 §9.5.4.1: Terminated MUST include result.duration.
563
+ // No Suspended cmi5 doesn't define that verb (§9.3); the LMS
564
+ // handles resume vs Abandoned itself when a new session opens
565
+ // against an active registration (§9.3.6).
566
+ // cmi5 §9.5.4.1 — Terminated MUST include result.duration.
296
567
  this.#publisher
297
568
  .sendStatement({
298
569
  verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
299
570
  result: { duration },
571
+ context: this.#cmi5Context(),
300
572
  })
573
+ .then(warnOnLRSReject('Terminated'))
301
574
  .catch((err) => {
302
575
  console.warn('Tessera cmi5: failed to send Terminated statement', err);
303
576
  });
304
577
  }
305
578
 
579
+ /**
580
+ * cmi5 §10.2.6 — explicit-Exit path. Terminate, wait for the
581
+ * publisher queue to drain so Terminated lands first, then redirect
582
+ * to `returnURL`. `terminate()` alone (called from pagehide) can't
583
+ * redirect — the page is already unloading.
584
+ */
585
+ async exit(): Promise<void> {
586
+ this.terminate();
587
+ if (this.#publisher) {
588
+ // chainTask with a no-op task awaits the queue head.
589
+ try {
590
+ await this.#publisher.chainTask(async () => {});
591
+ } catch {
592
+ // never rejects today; don't let a refactor block redirect.
593
+ }
594
+ }
595
+ if (this.#returnURL && typeof window !== 'undefined') {
596
+ window.location.assign(this.#returnURL);
597
+ }
598
+ }
599
+
306
600
  // ---- Private helpers ----
307
601
 
308
- #buildStateUrl(): string {
602
+ /**
603
+ * Build the cmi5 context for a Defined Statement, starting from the
604
+ * LMS contextTemplate (§9.6.2 — AU MUST NOT overwrite). Adds the
605
+ * cmi5 Category Activity (§9.6.2.1), the moveOn Category for
606
+ * Completed/Passed/Failed (§9.6.2.2), and the masteryScore extension
607
+ * for Passed/Failed (§9.6.3.2).
608
+ */
609
+ #cmi5Context(
610
+ opts: { moveOn?: boolean; mastery?: boolean } = {}
611
+ ): Record<string, unknown> {
612
+ const tmpl = this.#launchData?.contextTemplate ?? {};
613
+ const tmplActivities = (tmpl.contextActivities ?? {}) as Record<string, unknown>;
614
+
615
+ // Concat-dedupe category to preserve any template-supplied entries
616
+ // (§10.2.1 forbids overwriting them).
617
+ const seen = new Set<string>();
618
+ const category: Array<{ id: string; objectType: string }> = [];
619
+ const push = (id: string) => {
620
+ if (!seen.has(id)) {
621
+ seen.add(id);
622
+ category.push({ id, objectType: 'Activity' });
623
+ }
624
+ };
625
+ const templateCategory = Array.isArray((tmplActivities as { category?: unknown }).category)
626
+ ? ((tmplActivities as { category: Array<{ id: string; objectType?: string }> }).category)
627
+ : [];
628
+ for (const c of templateCategory) {
629
+ if (c && typeof c.id === 'string') push(c.id);
630
+ }
631
+ push(CMI5_CATEGORY_CMI5);
632
+ if (opts.moveOn) push(CMI5_CATEGORY_MOVEON);
633
+
634
+ const contextActivities: Record<string, unknown> = {
635
+ ...tmplActivities,
636
+ category,
637
+ };
638
+
639
+ const ctx: Record<string, unknown> = {
640
+ ...(tmpl as Record<string, unknown>),
641
+ contextActivities,
642
+ };
643
+ // cmi5 §9.6.3.2 — masteryScore extension is scoped to Passed/Failed.
644
+ if (opts.mastery && this.#masteryScore !== null) {
645
+ ctx.extensions = {
646
+ ...((tmpl.extensions ?? {}) as Record<string, unknown>),
647
+ [CMI5_MASTERYSCORE_EXT]: this.#masteryScore,
648
+ };
649
+ }
650
+ return ctx;
651
+ }
652
+
653
+ #buildStateUrl(stateId: string = 'tessera-state'): string {
309
654
  const agentJson = JSON.stringify(this.#actor);
310
655
  const params = new URLSearchParams({
311
656
  activityId: this.#activityId,
312
657
  agent: agentJson,
313
- stateId: 'tessera-state',
658
+ stateId,
314
659
  });
315
660
  // registration is optional per CMI5 spec — omit it when not provided
316
661
  if (this.#registration) {
@@ -319,6 +664,58 @@ export class CMI5Adapter implements PersistenceAdapter {
319
664
  return `${this.#endpoint}activities/state?${params.toString()}`;
320
665
  }
321
666
 
667
+ /** cmi5 §11 — Agent Profile URL. Scoped to agent only (no activity/registration). */
668
+ #buildAgentProfileUrl(profileId: string): string {
669
+ const agentJson = JSON.stringify(this.#actor);
670
+ const params = new URLSearchParams({
671
+ agent: agentJson,
672
+ profileId,
673
+ });
674
+ return `${this.#endpoint}agents/profile?${params.toString()}`;
675
+ }
676
+
677
+ /** GET the cmi5 §10 `LMS.LaunchData` document. Null if absent — strict LRSes will then reject Defined Statements. */
678
+ async #fetchLaunchData(): Promise<CMI5LaunchData | null> {
679
+ try {
680
+ const url = this.#buildStateUrl(LMS_LAUNCH_DATA_STATE_ID);
681
+ const resp = await this.#xapiFetch(url, { method: 'GET' });
682
+ if (resp.ok) {
683
+ return (await resp.json()) as CMI5LaunchData;
684
+ }
685
+ console.warn(
686
+ `Tessera cmi5: LMS.LaunchData State GET returned ${resp.status}; ` +
687
+ 'cmi5 Defined Statements may be rejected by strict LRSes ' +
688
+ '(missing Publisher Activity / session id).'
689
+ );
690
+ } catch (err) {
691
+ console.warn(
692
+ `Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`
693
+ );
694
+ }
695
+ return null;
696
+ }
697
+
698
+ /** GET cmi5 §11.1 Learner Preferences. 404 is normal (no prefs set). */
699
+ async #fetchLearnerPreferences(): Promise<CMI5LearnerPreferences | null> {
700
+ try {
701
+ const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
702
+ const resp = await this.#xapiFetch(url, { method: 'GET' });
703
+ if (resp.ok) {
704
+ return (await resp.json()) as CMI5LearnerPreferences;
705
+ }
706
+ if (resp.status !== 404) {
707
+ console.warn(
708
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`
709
+ );
710
+ }
711
+ } catch (err) {
712
+ console.warn(
713
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`
714
+ );
715
+ }
716
+ return null;
717
+ }
718
+
322
719
  async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
323
720
  const headers = new Headers(options.headers);
324
721
  if (this.#authToken) {
@@ -9,6 +9,16 @@ export interface ScormDialect<TApi> {
9
9
  sessionTimeKey: string;
10
10
  /** Format `seconds` for the session-time field — HHMMSS for 1.2, ISO8601 for 2004. */
11
11
  formatDuration(seconds: number): string;
12
+ /**
13
+ * Per-spec maximum byte length for `cmi.suspend_data` (SCORM 1.2 RTE
14
+ * §3.4.5.2 = 4096; SCORM 2004 4E §4.2 = 64000). Used by `saveState` to
15
+ * warn once when the serialized payload would be silently truncated by
16
+ * the LMS. Treated as "characters" since SCORM data-model lengths are
17
+ * specified in characters and Tessera stores ASCII-safe JSON.
18
+ */
19
+ suspendDataLimit: number;
20
+ /** Human label for the limit warning, e.g. "SCORM 1.2 (4096 chars)". */
21
+ suspendDataLimitLabel: string;
12
22
  /** Per-interaction-row field config passed to `buildScormInteractionFields`. */
13
23
  interactionFields: {
14
24
  responseField: 'student_response' | 'learner_response';
@@ -34,6 +44,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
34
44
  protected readonly queue = new WriteQueue();
35
45
  #state: SavedState | null = null;
36
46
  #terminated = false;
47
+ #suspendOverflowWarned = false;
37
48
  protected interactionCount = 0;
38
49
 
39
50
  constructor(api: TApi, dialect: ScormDialect<TApi>) {
@@ -84,6 +95,19 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
84
95
  saveState(state: SavedState): void {
85
96
  this.#state = state;
86
97
  const json = JSON.stringify(state);
98
+ if (
99
+ !this.#suspendOverflowWarned &&
100
+ json.length > this.dialect.suspendDataLimit
101
+ ) {
102
+ this.#suspendOverflowWarned = true;
103
+ console.warn(
104
+ `Tessera: cmi.suspend_data is ${json.length} chars, over the ` +
105
+ `${this.dialect.suspendDataLimitLabel} limit. The LMS will likely ` +
106
+ `truncate it and the next resume will lose state. Reduce ` +
107
+ `usePersistence() payloads or switch export.standard to a ` +
108
+ `larger-limit standard (scorm2004/cmi5).`
109
+ );
110
+ }
87
111
  this.queue.enqueue(() =>
88
112
  this.dialect.setValue(this.api, 'cmi.suspend_data', json)
89
113
  );
@@ -19,6 +19,8 @@ export interface SCORM12API {
19
19
  const SCORM12_DIALECT: ScormDialect<SCORM12API> = {
20
20
  sessionTimeKey: 'cmi.core.session_time',
21
21
  formatDuration: formatHHMMSS,
22
+ suspendDataLimit: 4096,
23
+ suspendDataLimitLabel: 'SCORM 1.2 cmi.suspend_data 4096-char',
22
24
  interactionFields: {
23
25
  responseField: 'student_response',
24
26
  timestampField: 'time',