Repository navigation
fix(scorm): mark Welcome page unmarkForCompletion so LMS completion reports - #5
Merged
Merged
Conversation
…eports The 0.0.3 release reported 0% completion to an LMS even though the learner finished every quiz. Root cause, traced through the HAR of a real attempt plus Xerte's xttracking_scorm1.2.js + xenith.js: with navigation="Menu with Page Controls", Xerte inserts a built-in Table-of-Contents page as x_pages[0] AFTER toCompletePages is built from currActPage over the 43 content nodes. So every content page's page_nr (used by exitInteraction to mark completedPages[i]) is currActPage + 1 -- a systematic off-by-one: - toCompletePages[0] == 0 could only be matched by page_nr == 0 (the menu), which is never exited (menu pages are excluded from x_endPageTracking), so completedPages[0] (the first content page, "Welcome") stayed false forever. - getSuccessStatus() returns 'incomplete' whenever any completedPages[i] is false, so finishTracking() set cmi.core.lesson_status='incomplete' (a no-op against Moodle's pre-set 'incomplete'), and the LMS never left the incomplete state. cmi.core.score.raw WAS being sent (75, then 100) but never mattered to the displayed completion. Changes to source/data.xml + source/preview.xml (identical): - unmarkForCompletion="true" on the Welcome page (linkID="PG1781882348053"). This makes markedPages skip currActPage == 0, so toCompletePages becomes [1,2,...,42], aligning with the content pages' page_nr values. completedPages[0] (Welcome) is now marked true when the learner leaves it. 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 unmarkForCompletion="true" on the Welcome page and are byte-identical to the repo source. - play.php?template_id=1 returns 200; HTML5 editor opens. - SCORM export is valid SCORM 1.2 (one SCO, 13 MB) and the exported template.xml carries unmarkForCompletion="true", trackingMode="full", trackingWeight 1x7 + 21, trackingPassed="80%", 45 questions, 8 quizzes, 0 empty options -- all unchanged from 0.0.3 except the fix. - Drove the exported package against a SCORM 1.2 mock API: a completed run sends cmi.core.lesson_status="passed" (score.raw=100), a failing run sends lesson_status="failed" (score.raw=62.5), and an incomplete run sends lesson_status="incomplete" + cmi.core.exit="suspend". Before the fix the completed run reported "incomplete". Trade-off: the Final quiz (page_nr == 43) is still outside toCompletePages (no index 43), so its completion is not tracked in completedPages -- but its score is still tracked via its interactions and trackingWeight="21", so the LMS grade and pass/fail are correct. The Welcome page is no longer "required" for completion, which is acceptable for an intro page. A proper fix would be in the Xerte engine (xenith.js: build toCompletePages from page_nr after the menu is inserted), but that would not survive XOT rebuilds; this content-level workaround is used because the release workflow builds from the XOT engine. Do not remove unmarkForCompletion="true" from the Welcome page without a replacement fix -- re-introducing the off-by-one will silently break LMS completion reporting again. Guidance updates: - docs/PROJECT_CONTEXT.md: added "SCORM completion bug fix" section documenting the root cause, fix, and trade-off; noted the Welcome page carries unmarkForCompletion="true" in the current-state list. - docs/COURSE_VERIFICATION.md: added "LMS completion fix (post-0.0.3)" section with the HAR evidence, root cause, fix, and validation results. Generated with pi 0.80.3 and GLM 5.2
📖 Course content previewDownload the
|
RichardoC
added a commit
that referenced
this pull request
Sep 2, 2026
…es across sessions (#9) 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.com/claude-code) https://claude.ai/code/session_01P9uX9kuo8sErHsfBWBtDyL --- _Generated by [Claude Code](https://claude.ai/code/session_01P9uX9kuo8sErHsfBWBtDyL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The 0.0.3 release reported 0% completion to an LMS even
though the learner finished every quiz. Root cause, traced through the
HAR of a real attempt plus Xerte's xttracking_scorm1.2.js + xenith.js:
with navigation="Menu with Page Controls", Xerte inserts a built-in
Table-of-Contents page as x_pages[0] AFTER toCompletePages is built
from currActPage over the 43 content nodes. So every content page's
page_nr (used by exitInteraction to mark completedPages[i]) is
currActPage + 1 -- a systematic off-by-one:
menu), which is never exited (menu pages are excluded from
x_endPageTracking), so completedPages[0] (the first content page,
"Welcome") stayed false forever.
is false, so finishTracking() set cmi.core.lesson_status='incomplete'
(a no-op against Moodle's pre-set 'incomplete'), and the LMS never
left the incomplete state. cmi.core.score.raw WAS being sent (75, then
100) but never mattered to the displayed completion.
Changes to source/data.xml + source/preview.xml (identical):
(linkID="PG1781882348053"). This makes markedPages skip currActPage
== 0, so toCompletePages becomes [1,2,...,42], aligning with the
content pages' page_nr values. completedPages[0] (Welcome) is now
marked true when the learner leaves it.
Validated end-to-end through a running XOT (docker-container branch)
instance on host port 8088:
carry unmarkForCompletion="true" on the Welcome page and are
byte-identical to the repo source.
template.xml carries unmarkForCompletion="true", trackingMode="full",
trackingWeight 1x7 + 21, trackingPassed="80%", 45 questions, 8
quizzes, 0 empty options -- all unchanged from 0.0.3 except the fix.
run sends cmi.core.lesson_status="passed" (score.raw=100), a failing
run sends lesson_status="failed" (score.raw=62.5), and an incomplete
run sends lesson_status="incomplete" + cmi.core.exit="suspend".
Before the fix the completed run reported "incomplete".
Trade-off: the Final quiz (page_nr == 43) is still outside
toCompletePages (no index 43), so its completion is not tracked in
completedPages -- but its score is still tracked via its interactions
and trackingWeight="21", so the LMS grade and pass/fail are correct.
The Welcome page is no longer "required" for completion, which is
acceptable for an intro page. A proper fix would be in the Xerte engine
(xenith.js: build toCompletePages from page_nr after the menu is
inserted), but that would not survive XOT rebuilds; this content-level
workaround is used because the release workflow builds from the XOT
engine. Do not remove unmarkForCompletion="true" from the Welcome page
without a replacement fix -- re-introducing the off-by-one will silently
break LMS completion reporting again.
Guidance updates:
documenting the root cause, fix, and trade-off; noted the Welcome
page carries unmarkForCompletion="true" in the current-state list.
section with the HAR evidence, root cause, fix, and validation
results.
Generated with pi 0.80.3 and GLM 5.2