tessera-learn 0.0.4 → 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.
- package/AGENTS.md +18 -8
- package/dist/plugin/index.js +3 -2
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/plugin/export.ts +6 -2
- package/src/runtime/adapters/cmi5.ts +371 -94
- package/src/runtime/xapi/publisher.ts +24 -5
|
@@ -15,9 +15,11 @@ 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',
|
|
20
|
-
|
|
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.
|
|
21
23
|
} as const;
|
|
22
24
|
|
|
23
25
|
const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
|
|
@@ -25,6 +27,16 @@ const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interactio
|
|
|
25
27
|
const CMI5_MASTERYSCORE_EXT =
|
|
26
28
|
'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
|
|
27
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
|
+
|
|
28
40
|
export type CMI5MoveOn =
|
|
29
41
|
| 'Passed'
|
|
30
42
|
| 'Completed'
|
|
@@ -40,6 +52,67 @@ const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
|
|
|
40
52
|
'NotApplicable',
|
|
41
53
|
]);
|
|
42
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
|
+
|
|
43
116
|
/**
|
|
44
117
|
* CMI5 persistence adapter using xAPI.
|
|
45
118
|
*
|
|
@@ -67,9 +140,7 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
67
140
|
#durationSeconds = 0;
|
|
68
141
|
#state: SavedState | null = null;
|
|
69
142
|
#completedSent = false;
|
|
70
|
-
#completionStatus: 'incomplete' | 'complete' = 'incomplete';
|
|
71
143
|
#successSent = false;
|
|
72
|
-
#passed = false;
|
|
73
144
|
#terminated = false;
|
|
74
145
|
|
|
75
146
|
// cmi5 §8 launch params. masteryScore (when present) overrides the
|
|
@@ -77,7 +148,19 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
77
148
|
// authority. moveOn drives the optional Satisfied statement (§9.5.3).
|
|
78
149
|
#masteryScore: number | null = null;
|
|
79
150
|
#moveOn: CMI5MoveOn = 'NotApplicable';
|
|
80
|
-
|
|
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;
|
|
81
164
|
|
|
82
165
|
async init(): Promise<void> {
|
|
83
166
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -150,27 +233,105 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
150
233
|
`Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
|
|
151
234
|
);
|
|
152
235
|
}
|
|
153
|
-
const text = await resp.text();
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
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;
|
|
158
256
|
if (!this.#authToken) {
|
|
159
257
|
throw new Error(
|
|
160
|
-
'Tessera cmi5: fetch token request returned an empty
|
|
258
|
+
'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.'
|
|
161
259
|
);
|
|
162
260
|
}
|
|
163
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
|
+
|
|
164
311
|
this.#publisher = new XAPIPublisher({
|
|
165
312
|
endpoint: this.#endpoint,
|
|
166
313
|
auth: this.#authToken,
|
|
167
314
|
actor: this.#actor,
|
|
168
315
|
activityId: this.#activityId,
|
|
169
316
|
registration: this.#registration,
|
|
317
|
+
sessionId,
|
|
170
318
|
cmi5Mode: true,
|
|
171
319
|
});
|
|
172
320
|
await this.#publisher.init();
|
|
173
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
|
+
|
|
174
335
|
// Retrieve saved state from xAPI State API. The State API is a different
|
|
175
336
|
// URL than statements/, so it doesn't go through the publisher's send
|
|
176
337
|
// path — but we still use the same auth/headers.
|
|
@@ -179,22 +340,17 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
179
340
|
const resp = await this.#xapiFetch(stateUrl, { method: 'GET' });
|
|
180
341
|
if (resp.ok) {
|
|
181
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
|
+
);
|
|
182
347
|
}
|
|
183
|
-
} 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
|
+
);
|
|
184
352
|
this.#state = null;
|
|
185
353
|
}
|
|
186
|
-
|
|
187
|
-
// Send Initialized statement (queued through publisher). Log failures
|
|
188
|
-
// here too — the publisher's per-destination outcome covers transport
|
|
189
|
-
// errors but won't surface to console; lifecycle statements are rare
|
|
190
|
-
// enough that an explicit warning helps production triage.
|
|
191
|
-
await this.#publisher
|
|
192
|
-
.sendStatement({
|
|
193
|
-
verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
|
|
194
|
-
})
|
|
195
|
-
.catch((err) => {
|
|
196
|
-
console.warn('Tessera cmi5: failed to send Initialized statement', err);
|
|
197
|
-
});
|
|
198
354
|
}
|
|
199
355
|
|
|
200
356
|
/**
|
|
@@ -221,6 +377,26 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
221
377
|
return this.#moveOn;
|
|
222
378
|
}
|
|
223
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
|
+
|
|
224
400
|
getState(): SavedState | null {
|
|
225
401
|
return this.#state;
|
|
226
402
|
}
|
|
@@ -232,11 +408,16 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
232
408
|
// Terminated. We can't use sendStatement here (different URL/verb).
|
|
233
409
|
this.#publisher.chainTask(async () => {
|
|
234
410
|
try {
|
|
235
|
-
await this.#xapiFetch(this.#buildStateUrl(), {
|
|
411
|
+
const resp = await this.#xapiFetch(this.#buildStateUrl(), {
|
|
236
412
|
method: 'PUT',
|
|
237
413
|
headers: { 'Content-Type': 'application/json' },
|
|
238
414
|
body: JSON.stringify(state),
|
|
239
415
|
});
|
|
416
|
+
if (!resp.ok) {
|
|
417
|
+
console.warn(
|
|
418
|
+
`Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
240
421
|
} catch (err) {
|
|
241
422
|
console.warn('Tessera: Failed to save CMI5 state', err);
|
|
242
423
|
}
|
|
@@ -244,36 +425,42 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
244
425
|
}
|
|
245
426
|
|
|
246
427
|
setScore(score: number): void {
|
|
247
|
-
|
|
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));
|
|
248
435
|
}
|
|
249
436
|
|
|
250
437
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
251
|
-
this.#completionStatus = status;
|
|
252
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;
|
|
253
441
|
this.#completedSent = true;
|
|
442
|
+
// cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
|
|
254
443
|
const result: Record<string, unknown> = {
|
|
255
444
|
completion: true,
|
|
256
445
|
duration: formatISO8601Duration(this.#durationSeconds),
|
|
257
446
|
};
|
|
258
|
-
if (this.#score !== null) {
|
|
259
|
-
result.score = { scaled: this.#score / 100 };
|
|
260
|
-
}
|
|
261
447
|
this.#publisher
|
|
262
448
|
.sendStatement({
|
|
263
449
|
verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
|
|
264
450
|
result,
|
|
265
|
-
context: this.#
|
|
451
|
+
context: this.#cmi5Context({ moveOn: true }),
|
|
266
452
|
})
|
|
453
|
+
.then(warnOnLRSReject('Completed'))
|
|
267
454
|
.catch((err) => {
|
|
268
455
|
console.warn('Tessera cmi5: failed to send Completed statement', err);
|
|
269
456
|
});
|
|
270
|
-
this.#maybeSendSatisfied();
|
|
271
457
|
}
|
|
272
458
|
|
|
273
459
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
274
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;
|
|
275
463
|
this.#successSent = true;
|
|
276
|
-
this.#passed = status === 'passed';
|
|
277
464
|
|
|
278
465
|
const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
|
|
279
466
|
const verbName = status === 'passed' ? 'passed' : 'failed';
|
|
@@ -282,18 +469,38 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
282
469
|
duration: formatISO8601Duration(this.#durationSeconds),
|
|
283
470
|
};
|
|
284
471
|
if (this.#score !== null) {
|
|
285
|
-
|
|
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
|
+
}
|
|
286
493
|
}
|
|
287
494
|
this.#publisher
|
|
288
495
|
.sendStatement({
|
|
289
496
|
verb: { id: verb, display: { 'en-US': verbName } },
|
|
290
497
|
result,
|
|
291
|
-
context: this.#
|
|
498
|
+
context: this.#cmi5Context({ moveOn: true, mastery: true }),
|
|
292
499
|
})
|
|
500
|
+
.then(warnOnLRSReject(verbName === 'passed' ? 'Passed' : 'Failed'))
|
|
293
501
|
.catch((err) => {
|
|
294
502
|
console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
|
|
295
503
|
});
|
|
296
|
-
this.#maybeSendSatisfied();
|
|
297
504
|
}
|
|
298
505
|
|
|
299
506
|
setDuration(seconds: number): void {
|
|
@@ -334,7 +541,10 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
334
541
|
},
|
|
335
542
|
result,
|
|
336
543
|
})
|
|
337
|
-
.
|
|
544
|
+
.then(warnOnLRSReject('Answered'))
|
|
545
|
+
.catch((err) => {
|
|
546
|
+
console.warn('Tessera cmi5: failed to send Answered statement', err);
|
|
547
|
+
});
|
|
338
548
|
}
|
|
339
549
|
|
|
340
550
|
commit(): void {
|
|
@@ -350,87 +560,102 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
350
560
|
// must be the last statement of the cmi5 session (§9.3.6).
|
|
351
561
|
this.#publisher.markUnloading();
|
|
352
562
|
const duration = formatISO8601Duration(this.#durationSeconds);
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
.sendStatement({
|
|
358
|
-
verb: { id: VERBS.suspended, display: { 'en-US': 'suspended' } },
|
|
359
|
-
result: { duration },
|
|
360
|
-
})
|
|
361
|
-
.catch((err) => {
|
|
362
|
-
console.warn('Tessera cmi5: failed to send Suspended statement', err);
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
// 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.
|
|
366
567
|
this.#publisher
|
|
367
568
|
.sendStatement({
|
|
368
569
|
verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
|
|
369
570
|
result: { duration },
|
|
571
|
+
context: this.#cmi5Context(),
|
|
370
572
|
})
|
|
573
|
+
.then(warnOnLRSReject('Terminated'))
|
|
371
574
|
.catch((err) => {
|
|
372
575
|
console.warn('Tessera cmi5: failed to send Terminated statement', err);
|
|
373
576
|
});
|
|
374
577
|
}
|
|
375
578
|
|
|
376
|
-
// ---- Private helpers ----
|
|
377
|
-
|
|
378
579
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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.
|
|
382
584
|
*/
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
}
|
|
388
598
|
}
|
|
389
599
|
|
|
600
|
+
// ---- Private helpers ----
|
|
601
|
+
|
|
390
602
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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).
|
|
394
608
|
*/
|
|
395
|
-
#
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
|
413
630
|
}
|
|
414
|
-
|
|
631
|
+
push(CMI5_CATEGORY_CMI5);
|
|
632
|
+
if (opts.moveOn) push(CMI5_CATEGORY_MOVEON);
|
|
415
633
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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;
|
|
426
651
|
}
|
|
427
652
|
|
|
428
|
-
#buildStateUrl(): string {
|
|
653
|
+
#buildStateUrl(stateId: string = 'tessera-state'): string {
|
|
429
654
|
const agentJson = JSON.stringify(this.#actor);
|
|
430
655
|
const params = new URLSearchParams({
|
|
431
656
|
activityId: this.#activityId,
|
|
432
657
|
agent: agentJson,
|
|
433
|
-
stateId
|
|
658
|
+
stateId,
|
|
434
659
|
});
|
|
435
660
|
// registration is optional per CMI5 spec — omit it when not provided
|
|
436
661
|
if (this.#registration) {
|
|
@@ -439,6 +664,58 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
439
664
|
return `${this.#endpoint}activities/state?${params.toString()}`;
|
|
440
665
|
}
|
|
441
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
|
+
|
|
442
719
|
async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
|
443
720
|
const headers = new Headers(options.headers);
|
|
444
721
|
if (this.#authToken) {
|
|
@@ -568,11 +568,30 @@ export class XAPIPublisher {
|
|
|
568
568
|
})
|
|
569
569
|
);
|
|
570
570
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
571
|
+
// Append the LRS body to the error message so callers see the
|
|
572
|
+
// specific reason (e.g. "Forbidden cmi5 defined statement: ...").
|
|
573
|
+
// Cap at 500 chars; on read failure, fall back to bare status.
|
|
574
|
+
if (typeof resp.text !== 'function') {
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
status: resp.status,
|
|
578
|
+
error: new Error(`LRS responded ${resp.status}`),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return resp.text().then(
|
|
582
|
+
(respBody): SendOutcome => ({
|
|
583
|
+
ok: false,
|
|
584
|
+
status: resp.status,
|
|
585
|
+
error: new Error(
|
|
586
|
+
`LRS responded ${resp.status}${respBody ? `: ${respBody.slice(0, 500)}` : ''}`
|
|
587
|
+
),
|
|
588
|
+
}),
|
|
589
|
+
(): SendOutcome => ({
|
|
590
|
+
ok: false,
|
|
591
|
+
status: resp.status,
|
|
592
|
+
error: new Error(`LRS responded ${resp.status}`),
|
|
593
|
+
})
|
|
594
|
+
);
|
|
576
595
|
}
|
|
577
596
|
|
|
578
597
|
#resolveAuth(forceRefresh: boolean): Promise<string> {
|