Skip to content

fix(scorm): autosave results to LMS as the learner progresses#6

Merged
RichardoC merged 1 commit into
mainfrom
fix-save
Jul 14, 2026
Merged

fix(scorm): autosave results to LMS as the learner progresses#6
RichardoC merged 1 commit into
mainfrom
fix-save

Conversation

@RichardoC

Copy link
Copy Markdown
Owner

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('<script>' + x_params.script + '</script>') (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

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 <learningObject> 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('<script>' +
  x_params.script + '</script>') (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
@github-actions

Copy link
Copy Markdown

📖 Course content preview

Download the course-preview artifact from the workflow run and open index.html to read the full course (pages + quizzes, correct answers marked ✓):

This preview is generated from source/data.xml by tools/render_preview.py. It is for review only — the playable SCORM package is built by the release workflow.

@RichardoC RichardoC merged commit 2958e1a into main Jul 14, 2026
1 check passed
@RichardoC RichardoC deleted the fix-save branch July 14, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant