From 6004ba138db47aff2722569976c6d118a765eb1d Mon Sep 17 00:00:00 2001 From: Richard Tweed Date: Tue, 14 Jul 2026 11:29:23 +0100 Subject: [PATCH] fix(scorm): autosave results to LMS as the learner progresses Results (cmi.core.score.raw, cmi.core.lesson_status, interactions, cmi.suspend_data) were only reported to the LMS when the learner clicked Save Session (or the window unloaded). If the LMS viewer unloaded the SCO without reliably firing onbeforeunload, nothing was reported and the gradebook stayed empty mid-attempt. Root cause, traced through Xerte's xttracking_scorm1.2.js + xenith.js (content-level, not fixable in the engine files here -- the release workflow builds the engine from the docker-container branch of RichardoC/xerteonlinetoolkits, so engine JS edits do not persist): - exitInteraction() (called on every page leave / quiz answer) issues many LMSSetValue calls but NEVER calls LMSCommit. - doLMSCommit() is called ONLY inside finishTracking(), which is called ONLY from XTTerminate() (Save Session button / window.onbeforeunload). - SCORM 1.2 only persists LMSSetValue to the LMS backend on LMSCommit, so all progress stayed in the API adapter's in-memory model until terminate. Worse, cmi.core.score.raw and cmi.core.lesson_status are *set* only inside finishTracking(), so even a bare mid-session commit would not update the gradebook. Fix (content-level, no engine/vendoring change -- mirrors the unmarkForCompletion workaround pattern): Changes to source/data.xml + source/preview.xml (identical): - script="..." attribute on the root. `script` is an official, optional Nottingham root property (wizard data.xwd line 261: "Add JavaScript to this project. The code will run after the project interface has been set up but before any pages have loaded"); xenith.js injects it once globally via $x_head.append('') (line 2269). The script: 1. Defines persist() -- replays the score/status half of finishTracking() (cmi.core.lesson_status, cmi.core.score.raw/min/max, cmi.core.exit, cmi.suspend_data) from the engine's own global `state`, then calls doLMSCommit(). Guarded to be a no-op outside a live SCORM normal session (state.initialised, scormmode === 'normal', trackingmode !== 'none', !state.finished, doLMSCommit + state.getSuccessStatus present) -- so play.php, xAPI, and noop tracking are unaffected. 2. Wraps the global XTExitPage and XTExitInteraction so every page leave and quiz answer flushes immediately -- the gradebook updates as the learner progresses. 3. A 30s setInterval heartbeat + a pagehide listener as safety nets for idle learners and LMS viewers that unload without events. The attribute value uses single quotes, !== -1 instead of >= 0, and nested if instead of &&, so it contains no XML-special characters (& < > ") and sits raw in the XML with no escaping concerns. Validated end-to-end through a running XOT (docker-container branch) instance on host port 8088: - Pushed source/data.xml + source/preview.xml into the container; both carry the root script attribute and are byte-identical to the repo source. The original engine JS is unchanged (no persistProgress in /var/www/xerte/modules/xerte/scorm1.2/xttracking_scorm1.2.js). - play.php?template_id=1 returns 200; HTML5 editor opens (200) with 44 tree nodes and no lockfile prompt. - Editor Publish round-trip PRESERVES the script attribute in data.xml byte-for-byte (build_lo_data loads ALL root attributes including script; editor/upload.php process() re-adds every attribute via addAttribute -- no filtering by wizard-declared names; makeAbsolute only rewrites FileLocation + '...' media refs, absent from the script). data.xml/preview.xml in the container remain identical to the repo source after Publish. - SCORM export is valid SCORM 1.2 (one SCO, ~13 MB) and the exported template.xml carries the autosave script (xPersistProgress count = 1), trackingMode="full", trackingWeight 1x7 + 21, trackingPassed="80%", unmarkForCompletion="true" x1, 45 questions, 8 quizzes judge="true", 27 delaySecs="0", 0 empty options -- all unchanged from 0.0.5 except the added script. - Drove the exported package (original, unpatched engine JS) against a SCORM 1.2 mock API harness: with the script, 5 LMSCommit calls fire DURING progress (cmi.core.score.raw going 0 -> 100 as a quiz is answered, lesson_status and suspend_data set, 32 interaction setValue calls) plus 1 on terminate; WITHOUT the script, 0 commits fire during progress and only 1 on terminate (the original bug). All 7 assertions pass (LMSInitialize, commits during progress, score.raw + lesson_status + suspend_data set during progress, interactions recorded, LMSFinish once). Trade-off: the script wraps XTExitPage/XTExitInteraction at runtime, so it depends on those globals existing and on the engine's global `state` object shape (getSuccessStatus/getRawScore/getMinScore/getMaxScore/ getVars) -- all stable Nottingham engine internals. If the engine is ever patched upstream to commit on exitInteraction, this script becomes redundant (harmless -- persist() would just call doLMSCommit a second time, which is idempotent) and can be dropped. Do not remove or empty the root script attribute without a replacement -- re-introducing the terminate-only commit will silently revert to "results only on Save". Guidance updates: - docs/PROJECT_CONTEXT.md: added "SCORM autosave on progress (root script attribute)" section documenting the root cause, fix, safety, and durability; noted the root script attribute in the current-state list; added `grep -c 'xPersistProgress'` and `unmarkForCompletion` lines to the verification checklist. - docs/COURSE_VERIFICATION.md: added "SCORM autosave on progress (root script attribute)" section with the problem, fix, validation results, and the pending LMS re-test. Pending (convention 9 -- re-test on LMS): confirm a learner attempt now shows the gradebook updating incrementally (not only on Save), and that a completed attempt still reports lesson_status=passed with a non-zero grade. Generated with pi 0.80.6 and GLM 5.2 --- docs/COURSE_VERIFICATION.md | 37 ++++++++++++++++++++ docs/PROJECT_CONTEXT.md | 68 +++++++++++++++++++++++++++++++++++++ source/data.xml | 2 +- source/preview.xml | 2 +- 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/docs/COURSE_VERIFICATION.md b/docs/COURSE_VERIFICATION.md index 6eb39db..636879d 100644 --- a/docs/COURSE_VERIFICATION.md +++ b/docs/COURSE_VERIFICATION.md @@ -145,3 +145,40 @@ confirm `play.php` returns 200, export a SCORM package, and grep the exported Welcome page and the question count / `trackingWeight` / `trackingPassed` / empty-option count are unchanged. Then re-test on LMS that a completed attempt reports `lesson_status=passed` and a non-zero grade. + +## SCORM autosave on progress (root `script` attribute) + +**Problem**: results (`cmi.core.score.raw`, `cmi.core.lesson_status`, +interactions, `suspend_data`) were only reported to the LMS when the learner +clicked **Save Session** (or the window unloaded). The engine's +`exitInteraction()` calls `LMSSetValue` but never `LMSCommit`, and +`finishTracking()` (which commits and sets score/status) runs only at +`XTTerminate()` (Save / `onbeforeunload`). So nothing flushed mid-session. + +**Fix applied** (content-level, no engine change): a `script="..."` attribute +on the `` root in `source/data.xml` + `source/preview.xml` — +an official Nottingham root property (wizard `data.xwd` line 261) that xenith +injects once globally before pages load. The script wraps the global +`XTExitPage`/`XTExitInteraction` to call a `persist()` helper after every page +leave / quiz answer: it sets `cmi.core.lesson_status`, `cmi.core.score.raw/min/max`, +`cmi.core.exit`, `cmi.suspend_data` from the engine's own global `state`, then +calls `doLMSCommit()`. A 30 s heartbeat and `pagehide` listener are safety +nets. Guards make it a no-op outside a live SCORM `normal` session. + +**Verified**: +- `play.php` returns 200; editor opens (200) and a **Publish round-trip + preserved the `script` attribute** in `data.xml` byte-for-byte (editor loads + all root attrs via `build_lo_data`; `upload.php` `process()` re-adds every + attr — no filtering). +- Fresh SCORM export's `template.xml` carries the script (`xPersistProgress` + count = 1); all content/tracking attrs unchanged (45 questions, 80% pass, + `trackingMode=full`, `trackingWeight` 1×7 + 21, `unmarkForCompletion`×1, 0 + empty options, 27 `delaySecs=0`). +- Mock SCORM 1.2 API harness loading the **original unpatched engine JS** + + this script: **5 commits fire during progress** (score 0→100 as a quiz is + answered) vs **0** without the script (only 1 commit on terminate). See + `PROJECT_CONTEXT.md` § "SCORM autosave on progress" for full detail. + +**Pending (convention 9 — re-test on LMS)**: confirm a learner attempt now +shows the gradebook updating incrementally (not only on Save), and that a +completed attempt still reports `lesson_status=passed` with a non-zero grade. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 976ff97..0a5642d 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -69,6 +69,9 @@ with agents and building agents. - **No Trial MCQ** (an early trial page was removed). - **Welcome page carries `unmarkForCompletion="true"`** — see "SCORM completion bug fix" below for why; it is the fix for LMS completion tracking. +- **Root `` carries a `script="..."` attribute** (SCORM + autosave) — see "SCORM autosave on progress" below; it makes results report + to the LMS as the learner progresses instead of only on Save Session. - `play.php` returns 200; the export is `Secure_code_development_scorm.zip`. ## Conventions (MANDATORY for any content changes) @@ -191,6 +194,69 @@ persist across XOT rebuilds. a replacement fix — re-introducing the off-by-one will silently break LMS completion reporting again. +## SCORM autosave on progress (root `script` attribute) + +**Symptom**: on LMS, results (`cmi.core.score.raw`, `cmi.core.lesson_status`, +interactions, `suspend_data`) were only reported to the LMS when the learner +clicked **Save Session** (or the window unloaded). If the LMS viewer unloaded +the SCO without reliably firing `onbeforeunload`, nothing was reported and the +gradebook stayed empty mid-attempt. + +**Root cause** (Xerte Nottingham engine behaviour, **not** a bug we can fix in +content via the engine files — the release workflow builds the engine from the +`docker-container` branch of `RichardoC/xerteonlinetoolkits`, so engine JS +edits do not persist): in `xttracking_scorm1.2.js`, `exitInteraction()` (called +on every page leave / quiz answer) issues many `LMSSetValue` calls but **never** +calls `LMSCommit`. `doLMSCommit()` is called **only** inside `finishTracking()`, +which is called **only** from `XTTerminate()` (Save Session / `onbeforeunload`). +SCORM 1.2 only persists `LMSSetValue` to the LMS backend on `LMSCommit`, so all +progress stayed in the API adapter's in-memory model until terminate. Worse, +`cmi.core.score.raw` and `cmi.core.lesson_status` are *set* only inside +`finishTracking()`, so even a mid-session commit would not update the grade. + +**Fix** (content-level, no engine/vendoring change): a `script="..."` attribute +on the `` root in `source/data.xml` + `source/preview.xml`. +`script` is an **official, optional Nottingham root property** (wizard +`data.xwd` line 261: *"Add JavaScript to this project. The code will run after +the project interface has been set up but before any pages have loaded"*); +xenith.js injects it once globally via `$x_head.append('')` (line 2269). The script: + +1. Defines `persist()` — replays the score/status half of `finishTracking()` + (`cmi.core.lesson_status`, `cmi.core.score.raw/min/max`, `cmi.core.exit`, + `cmi.suspend_data`) then calls `doLMSCommit()`, using the engine's own global + `state` object (`state.getSuccessStatus()`, `state.getRawScore()`, + `state.getVars()`). Guards on `state.initialised`, `state.scormmode === + 'normal'`, `state.trackingmode !== 'none'`, `state.finished`, and the + presence of `doLMSCommit`/`state.getSuccessStatus` so it is a no-op outside a + live SCORM `normal` session (e.g. `play.php`, xAPI, noop tracking). +2. Wraps the global `XTExitPage` and `XTExitInteraction` so every page leave and + quiz answer flushes immediately — the gradebook updates as the learner + progresses. +3. A 30 s `setInterval` heartbeat as a safety net (idle learners, or LMS + viewers that unload without events), and a `pagehide` listener as a backup + flush if `onbeforeunload` does not fire. + +**Why this is safe and durable**: it is content, so the release workflow +exports it as part of `data.xml`/`template.xml` with no engine change. The +editor round-trips it: `build_lo_data` (toolbox.js) loads **all** root +attributes including `script`, and `editor/upload.php` `process()` re-adds +**every** attribute via `addAttribute` (no filtering by wizard-declared names). +Verified by an editor open → Publish round-trip: `script` survived in +`data.xml` byte-for-byte (timestamp updated, attribute intact). `makeAbsolute` +only rewrites `FileLocation + '...'` media refs (absent from the script) and +`addLineBreaks` only applies to textinput/textarea types, so the JS is not +mangled. The script attribute value contains no XML-special characters (`&`, +`<`, `>`, `"` — single quotes only, `!== -1` instead of `>= 0`, nested `if`s +instead of `&&`), so it sits raw in the XML with no escaping concerns. + +**Do not remove or empty the root `script` attribute** without a replacement — +re-introducing the terminate-only commit will silently revert to +"results only on Save". If the engine is ever patched upstream to commit on +`exitInteraction`, this `script` becomes redundant (harmless — its `persist()` +would just call `doLMSCommit` a second time, which is idempotent) and can be +dropped. + ## SCORM scoring (how the LMS grade is computed) SCORM 1.2 reports **one** `cmi.core.score.raw` (0–100) and **one** @@ -294,6 +360,8 @@ grep -oE 'judge="true"' /tmp/c.xml | wc -l # 8 grep -oE 'trackingWeight="[0-9]+"' /tmp/c.xml # 7x "1" + 1x "21" grep -oE 'trackingMode="[a-z_]+"' /tmp/c.xml # "full" grep -oE 'delaySecs="0"' /tmp/c.xml | wc -l # 27 (all bullets pages) +grep -c 'xPersistProgress' /tmp/c.xml # 1 (SCORM autosave script present on root) +grep -oE 'unmarkForCompletion="true"' /tmp/c.xml | wc -l # 1 (Welcome page) # empty options (must be 0): python3 -c "import re,html as H;x=open('/tmp/c.xml').read();print(sum(1 for m in re.finditer(r'