Skip to content

fix(scorm): stop writing unresumable progress records; keep quiz scores across sessions - #9

Merged
RichardoC merged 4 commits into
mainfrom
claude/scorm-tracking-issues-wfotbi
Sep 2, 2026
Merged

RichardoC merged 4 commits into
mainfrom
claude/scorm-tracking-issues-wfotbi

Conversation

@RichardoC

Copy link
Copy Markdown
Owner

Fixes the four problems reported from a real LMS attempt. All four come from how
xttracking_scorm1.2.js serialises cmi.suspend_data, so they are worked around from the
course's root script property — the release workflow rebuilds the engine from upstream XOT
on every build, so the engine itself cannot be patched here.

1. Course fails to load

getVars() saves the resume point as find(currentpageid) and pushes the result unchecked
(xttracking_scorm1.2.js:271-273). currentpageid is blank between leaving one page and
entering the next (exitInteraction() clears it at line 483) and on a revisit to the Table of
Contents, where XTEnterPage() is skipped for an already-built page. So the record held
"interactions":[null], and the next launch read page_nr off that null and threw:

TypeError: Cannot read properties of null (reading 'page_nr')
    at ScormTrackingState.setVars (xttracking_scorm1.2.js:238:64)
    at ScormTrackingState.initTracking (xttracking_scorm1.2.js:856:22)
    at XTInitialise (xttracking_scorm1.2.js:1391:15)
    at x_continueSetUp2 (common_html5/js/xenith.js:2275:2)

The exception unwinds the player's setup, so nothing renders — and because the bad value is
stored in suspend_data, every later launch fails the same way.

Two writers produced it: the autosave added in #6 flushed inside that blank window on every
page exit, and the engine's own finishTracking() does it when a learner saves or closes on
the contents page.

Fix. state.getVars is wrapped so an unresumable record is never written — nulls dropped,
and when currentpageid does not resolve, the page on screen (or the most recent page in the
history) is substituted. state.setVars is wrapped so records written by earlier builds are
healed on read, and state.initTracking is wrapped so an unforeseen bad record can only cost
the resume point, never the whole course.

2. Inconsistent saved-progress data

The blank resume point is the same root cause as above.

The reported "43 entries against 45 pages" is correct, not a mismatch. nrpages is
x_pageInfo.length = 44 content pages + 1 for the contents page Xerte splices in at
x_pages[0]. completedPages is sized from markedPages, which excludes that page and the
Welcome page (unmarkForCompletion="true", added in #5), giving 43 = page_nr 1…43, Welcome
through the final quiz. "Course complete" (page_nr 44) is deliberately not required. Now
documented in docs/PROJECT_CONTEXT.md so it does not get "fixed" later.

What is a real bug: setVars() restores completedPages verbatim, so a learner resuming
into a build with a different number of tracked pages keeps an array of the wrong length — and
getSuccessStatus() walks that array, so a short one reports completion early and a long one
blocks completion forever. It is now resized to state.toCompletePages.length, live tracking
options (nrpages, tracking mode, pass mark, page timeout) win over saved copies, and page
indexes that no longer exist are dropped — which also removes a second crash path, since
x_restorePagesViewed() writes x_pageInfo[i].viewed with no bounds check.

3. Completion reachability

Verified rather than assumed. test_full_walkthrough_reports_passed walks all 44 pages,
answers all 45 questions and asserts lesson_status="passed" with score.raw=100;
test_completion_survives_a_resume does the same across two sessions.

For the record, "all pages required" is weaker than it sounds: a page counts as complete when
it is left after more than 100 ms (exit() ignores shorter visits) and all its interactions
exist — and a quiz page registers all of its question interactions on load. So visiting a quiz
page is enough for completion; answering is what produces the score. The practical risk was
never the page count, it was the crash and the stale array above.

4. Score not captured

getVars() persists only the current page's interaction ("SCORM 1.2 only allows for 4kb of
suspend data"), and getdRawScore() averages the page interactions in memory. So every quiz
page score was discarded on resume: theme quizzes on Monday and the final quiz on Tuesday
graded as Tuesday alone, and a learner who answers questions without reaching a quiz's results
screen (no XTSetPageScore until then) reports score.raw=0 with cmi.interactions.* full of
answers — the reported combination.

Fix. Scored pages are persisted compactly as xtPages
([page_nr, score, weighting, nrinteractions, id]) and restored as page interactions, so
getdRawScore() sees the earlier quizzes again. Because scoremode is last, re-taking a
quiz still overwrites the remembered score, and no page is counted twice. A size guard keeps
the record inside the 4096-character limit by shedding, in order: old page history, history,
pagesViewed, then xtPages.

Maintainability

The script is no longer maintained by hand inside an XML attribute.
source/scorm_progress.js is the source of truth and tools/sync_root_script.py compiles it
into data.xml/preview.xml (--check verifies they are in sync; CI and a test both assert
it). The compiler also enforces the existing "no <, >, & or "" rule that keeps the
attribute round-tripping through the XOT editor unescaped.

Tests

tests/build_scorm_package.sh exports a package with a real XOT container — the same
import/export path as the release workflow — and tests/test_scorm_tracking.py drives that
package in headless Chromium against a mock SCORM 1.2 LMS.
.github/workflows/test.yml runs both on every PR.

15/15 pass on this branch, built from a clean clone in a fresh container. 6 of them fail
on main
, including a 45-second start-up timeout on the reported record. The suite covers:
loading from the corrupt record, terminate on the contents page, out-of-range pagesViewed,
stale completedPages resizing, resume position, the size guard under 5000 history entries,
in-session quiz scoring, score retention across a resume, the 75/25 weighting across two
sessions, and the two full-walkthrough completion runs.

Also confirmed in a real browser (Playwright MCP, headed Chrome) against the affected learner's
record: main renders a blank page with that TypeError in the console; this branch renders the
contents with the learner's viewed pages ticked, and a click through into a quiz reports
interactions and a null-free 1098-character record with a valid resume point.

Upstream

The engine bug is unfixed upstream — thexerteproject/xerteonlinetoolkits master (32c009b)
has the identical unguarded code, and there is no issue for it. An issue with a minimal
reproduction (verified against a build of this course with the root script attribute
stripped, so no code of ours is involved) is drafted and ready to raise. If it is ever fixed
upstream, the autosave half of this script becomes redundant and harmless; the repair half
stays relevant until getVars()/setVars() are guarded.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P9uX9kuo8sErHsfBWBtDyL


Generated by Claude Code

claude added 2 commits August 11, 2026 16:11
…es across sessions

A learner's attempt failed to load: xttracking_scorm1.2.js threw "Cannot read
properties of null (reading 'page_nr')" from setVars, called by initTracking /
XTInitialise, and the package never rendered. All four reported problems trace
back to how the engine serialises cmi.suspend_data, so they are worked around
from the root `script` property (the engine itself is rebuilt from upstream XOT
on every release and cannot be patched here).

1. Never write a record that cannot be resumed. getVars() serialises the resume
   point as find(currentpageid) and pushes the result without checking it;
   currentpageid is blank between leaving one page and entering the next, and on
   a revisit to the table of contents, so the saved record contained
   "interactions":[null]. On the next launch setVars() read page_nr off that
   null and threw, which unwound the player's setup. The wrapper now drops nulls
   and substitutes the page on screen (or the last page in the history) as the
   resume point. Records written by earlier builds are healed on the way in, and
   initTracking is made non-fatal so a bad record can only cost the resume
   point, never the whole course.

2. Keep completedPages in sync with the build. It was restored verbatim, so a
   learner resuming into a build with a different number of tracked pages kept
   an array of the wrong length - and getSuccessStatus() walks that array, so a
   short one reports completion early and a long one blocks it forever. It is
   now resized to state.toCompletePages.length, live tracking options win over
   saved copies, and page indexes that no longer exist are dropped (which also
   removes a crash in x_restorePagesViewed). The reported "43 entries against 45
   pages" is not a mismatch: 45 counts the injected contents page, 43 excludes
   it and the unmarked Welcome page. Documented.

3. Completion with trackingMode="full" is now covered by a test that walks all
   44 pages, answers all 45 questions and asserts lesson_status=passed with
   score.raw=100, rather than being assumed.

4. Keep quiz scores across sessions. getVars() persists only the current page's
   interaction, so every quiz page score was lost on resume and getdRawScore()
   averaged just the current session - which is how score.raw sits at 0 for a
   learner with plenty of logged interactions. Scored pages are now persisted
   compactly (xtPages) and restored as page interactions, with a size guard that
   keeps the record inside the 4096-character suspend_data limit.

The script is no longer maintained by hand inside an XML attribute:
source/scorm_progress.js is the source of truth and tools/sync_root_script.py
compiles it into data.xml/preview.xml, enforcing the "no < > & or " " rule that
keeps the attribute round-tripping through the XOT editor unescaped.

Tests: tests/build_scorm_package.sh exports a package with a real XOT container
(the release workflow's own import/export path) and tests/test_scorm_tracking.py
drives that package in headless Chromium against a mock SCORM 1.2 LMS. 14 tests,
all passing here; 6 of them fail on the previous build, including a start-up
timeout on the reported record. CI runs both on every PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9uX9kuo8sErHsfBWBtDyL
The shedding ladder in the getVars wrapper was the one piece of new logic no
test exercised. It drops old page history first, then history, then pagesViewed,
then remembered quiz scores, so a record that cannot fit still resumes.

Drives a learner with 5000 page-history entries: the record stays inside the
4096-character SCORM 1.2 limit, keeps its resume point, all 43 completedPages
and all 8 quiz page scores, and resumes with the score intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9uX9kuo8sErHsfBWBtDyL
@github-actions

github-actions Bot commented Sep 2, 2026 •

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.

Review of the branch found a case the fix missed, with the same symptom it set
out to eliminate. Interactions were checked for nulls but not for whether their
page still exists, and XTStartPage hands the saved page_nr straight to the
player, which indexes x_pageInfo without a bounds check. A learner whose last
save was on a page that a later build removed got
"Cannot read properties of null" replaced by
"Cannot read properties of undefined (reading 'standalone')" - still a blank
course, and reachable in exactly the resize-the-course scenario the
completedPages normalisation was written for. Those entries are now dropped
too, so the resume point falls back to the contents page.

Two smaller things found in the same review:

- state.finished is never set by the engine, so the guard against saving after
  LMSFinish did nothing: onbeforeunload runs XTTerminate, then pagehide
  autosaved again, writing six values a conformant LMS rejects with error 301
  and losing a page-history entry finishTracking had already dropped. Track
  termination ourselves by wrapping XTTerminate, which has to happen
  synchronously before xenith assigns it to onbeforeunload.
- A save taken mid-page persisted the resume entry as 'entered'. The engine
  only calls reenter() for an 'exited' interaction, so the entry kept its old
  start timestamp and the next session's first page exit reported a duration
  spanning the gap between sessions. The saved copy is now marked exited; the
  live interaction is untouched.

Three new tests, each failing on the previous build, plus the stale
completedPages test now builds its record from a scored session so it can
assert status rather than only length, and the corrupt-record test asserts the
title rendered instead of a character count. 18/18 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9uX9kuo8sErHsfBWBtDyL
The comment stripper is a regex, so a /* or */ inside a string literal would
silently delete everything up to the next one, and node --check would happily
accept the truncated result. Nothing in the current source does that, but the
failure mode is invisible in a tool whose whole job is turning reviewable
source into shipped code.

Assert every function the source defines survives into the compiled output.
Verified against the pathological case: a source with '/*' and '*/' held in
two string literals now fails the compile instead of shipping a gutted script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9uX9kuo8sErHsfBWBtDyL
@RichardoC
RichardoC merged commit b5db27f into main Sep 2, 2026
2 checks passed
@RichardoC
RichardoC deleted the claude/scorm-tracking-issues-wfotbi branch September 2, 2026 16:20
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.

2 participants