Skip to content

fix(scorm): mark Welcome page unmarkForCompletion so LMS completion reports - #5

Merged
RichardoC merged 1 commit into
mainfrom
fix-report
Jul 8, 2026
Merged

RichardoC merged 1 commit into
mainfrom
fix-report

Conversation

@RichardoC

Copy link
Copy Markdown
Owner

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

…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
@github-actions

github-actions Bot commented Jul 8, 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.

@RichardoC
RichardoC merged commit 683131b into main Jul 8, 2026
1 check passed
@RichardoC
RichardoC deleted the fix-report branch July 8, 2026 11:25
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>
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