Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/COURSE_VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<learningObject>` 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.
68 changes: 68 additions & 0 deletions docs/PROJECT_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<learningObject>` 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)
Expand Down Expand Up @@ -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 `<learningObject>` 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('<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`) 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**
Expand Down Expand Up @@ -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'<option ([^>]*?)/>',x) if H.unescape(H.unescape(re.search(r'text=\"([^\"]*)\"',m.group(1)).group(1)).replace('<p>','').replace('</p>','').strip())==''))"
curl -s -o /dev/null -w "play=%{http_code}\n" http://localhost:8088/play.php?template_id=1 # 200
Expand Down
2 changes: 1 addition & 1 deletion source/data.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0"?>
<learningObject editorVersion="3" targetFolder="Nottingham" name="Secure code development" language="en-GB" navigation="Menu with Page Controls" textSize="12" theme="default" themeIcons="false" displayMode="fill window" responsive="true" trackingMode="full" trackingPassed="80%" trackingPageTimeout="0" forceTrackingMode="false" restartOptions="ask" hideSaveSession="false" saveSessionLabel="Save Session" closeSessionLabel="Close Session" saveSessionIc="false" saveSessionIcon="fas fa-save"><text unmarkForCompletion="true" linkID="PG1781882348053" name="Welcome &amp;amp; how to use this course"><![CDATA[<h2>Welcome to Secure code development</h2>
<learningObject editorVersion="3" targetFolder="Nottingham" name="Secure code development" language="en-GB" navigation="Menu with Page Controls" textSize="12" theme="default" themeIcons="false" displayMode="fill window" responsive="true" trackingMode="full" trackingPassed="80%" trackingPageTimeout="0" forceTrackingMode="false" restartOptions="ask" hideSaveSession="false" saveSessionLabel="Save Session" closeSessionLabel="Close Session" saveSessionIc="false" saveSessionIcon="fas fa-save" script="(function(){ if(typeof window==='undefined'){return;} if(window.__xtAutosave){return;} window.__xtAutosave=true; function persist(){ try{ if(typeof state==='undefined'||!state||!state.initialised||state.finished){return;} if(typeof doLMSCommit!=='function'||typeof state.getSuccessStatus!=='function'){return;} if(state.trackingmode==='none'||state.scormmode!=='normal'){return;} var ls=state.getSuccessStatus(); setValue('cmi.core.lesson_status',ls); if(typeof x_pageHistory!=='undefined'){state.pageHistory=x_pageHistory;} if(typeof x_pagesViewed==='function'){state.pagesViewed=x_pagesViewed();} var susp=state.getVars(); setValue('cmi.core.exit', ls==='incomplete'?'suspend':''); setValue('cmi.suspend_data',susp); var sup=getValue('cmi.core.score._children'); setValue('cmi.core.score.raw',state.getRawScore()); if(sup.indexOf('min')!==-1){setValue('cmi.core.score.min',state.getMinScore());} if(sup.indexOf('max')!==-1){setValue('cmi.core.score.max',state.getMaxScore());} doLMSCommit(); }catch(e){} } window.xPersistProgress=persist; function wrap(n){ if(typeof window[n]!=='function'){return;} var o=window[n]; window[n]=function(){ var r=o.apply(this,arguments); try{persist();}catch(e){} return r; }; } function install(){ if(typeof state==='undefined'||!state||!state.initialised){ setTimeout(install,200); return; } wrap('XTExitPage'); wrap('XTExitInteraction'); setInterval(persist,30000); } install(); if(typeof window.addEventListener==='function'){ window.addEventListener('pagehide',persist); } })();"><text unmarkForCompletion="true" linkID="PG1781882348053" name="Welcome &amp;amp; how to use this course"><![CDATA[<h2>Welcome to Secure code development</h2>

<p>This course is for <strong>engineers building software with agents</strong> and for <strong>engineers building agents</strong>. Whether you are designing agentic systems, integrating LLMs, or writing the services that agents call, secure code development matters.</p>

Expand Down
2 changes: 1 addition & 1 deletion source/preview.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0"?>
<learningObject editorVersion="3" targetFolder="Nottingham" name="Secure code development" language="en-GB" navigation="Menu with Page Controls" textSize="12" theme="default" themeIcons="false" displayMode="fill window" responsive="true" trackingMode="full" trackingPassed="80%" trackingPageTimeout="0" forceTrackingMode="false" restartOptions="ask" hideSaveSession="false" saveSessionLabel="Save Session" closeSessionLabel="Close Session" saveSessionIc="false" saveSessionIcon="fas fa-save"><text unmarkForCompletion="true" linkID="PG1781882348053" name="Welcome &amp;amp; how to use this course"><![CDATA[<h2>Welcome to Secure code development</h2>
<learningObject editorVersion="3" targetFolder="Nottingham" name="Secure code development" language="en-GB" navigation="Menu with Page Controls" textSize="12" theme="default" themeIcons="false" displayMode="fill window" responsive="true" trackingMode="full" trackingPassed="80%" trackingPageTimeout="0" forceTrackingMode="false" restartOptions="ask" hideSaveSession="false" saveSessionLabel="Save Session" closeSessionLabel="Close Session" saveSessionIc="false" saveSessionIcon="fas fa-save" script="(function(){ if(typeof window==='undefined'){return;} if(window.__xtAutosave){return;} window.__xtAutosave=true; function persist(){ try{ if(typeof state==='undefined'||!state||!state.initialised||state.finished){return;} if(typeof doLMSCommit!=='function'||typeof state.getSuccessStatus!=='function'){return;} if(state.trackingmode==='none'||state.scormmode!=='normal'){return;} var ls=state.getSuccessStatus(); setValue('cmi.core.lesson_status',ls); if(typeof x_pageHistory!=='undefined'){state.pageHistory=x_pageHistory;} if(typeof x_pagesViewed==='function'){state.pagesViewed=x_pagesViewed();} var susp=state.getVars(); setValue('cmi.core.exit', ls==='incomplete'?'suspend':''); setValue('cmi.suspend_data',susp); var sup=getValue('cmi.core.score._children'); setValue('cmi.core.score.raw',state.getRawScore()); if(sup.indexOf('min')!==-1){setValue('cmi.core.score.min',state.getMinScore());} if(sup.indexOf('max')!==-1){setValue('cmi.core.score.max',state.getMaxScore());} doLMSCommit(); }catch(e){} } window.xPersistProgress=persist; function wrap(n){ if(typeof window[n]!=='function'){return;} var o=window[n]; window[n]=function(){ var r=o.apply(this,arguments); try{persist();}catch(e){} return r; }; } function install(){ if(typeof state==='undefined'||!state||!state.initialised){ setTimeout(install,200); return; } wrap('XTExitPage'); wrap('XTExitInteraction'); setInterval(persist,30000); } install(); if(typeof window.addEventListener==='function'){ window.addEventListener('pagehide',persist); } })();"><text unmarkForCompletion="true" linkID="PG1781882348053" name="Welcome &amp;amp; how to use this course"><![CDATA[<h2>Welcome to Secure code development</h2>

<p>This course is for <strong>engineers building software with agents</strong> and for <strong>engineers building agents</strong>. Whether you are designing agentic systems, integrating LLMs, or writing the services that agents call, secure code development matters.</p>

Expand Down