From ab2aaa38674241df72433803c0ac96349b2b42af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:51:26 +0000 Subject: [PATCH] feat(progress): show learners what completion requires; honest quiz ticks; header progress bar Learners could not tell how far through the course they were or whether they had done what the LMS needs. The contents-page tick only meant a page had been opened, and SCORM 1.2 gives the LMS nothing but incomplete/passed/failed, so Moodle's "Learning objects" grade sits at 0% until the very end. The LMS side is structural, so make the course itself honest: - Status panel: the root script fills every `.xtStatusPanel` element (one in the menu text of the contents page, one at the top of the Course complete page) with required pages done out of 43, the pages still to visit, each quiz result or "not yet submitted", the weighted score against the pass mark, and the exact status the LMS currently holds, in plain language. Refreshed on page load, menu tick updates, page exits and quiz submission. - Quiz ticks mean "submitted": XTSetPageScore (the quiz results screen) now marks the page interaction complete; the flag is persisted as a sixth element of each xtPages row and restored on resume (older records infer it from a non-zero score). An opened-but-unsubmitted quiz shows a half-filled circle with an explanatory label instead of a tick. - Header progress bar (Xerte's own): progressBarType="header2" with a milestone marker on each quiz page and the label "{x}% of pages viewed". header1 inserts after the header logo, which this course lacks, so it built nothing. - Welcome page explains the indicators; Course complete no longer claims the result "has been recorded" unconditionally. Docs: README section on what learners and the LMS see, with the Moodle SCORM activity settings that matter (grading method, completion tracking, attempt status display) and why SCORM 2004 / multi-SCO do not help; PROJECT_CONTEXT section on the mechanism and what was deliberately left unchanged (which pages are required; the strict > pass check). Tests: four new tracking tests plus panel assertions in the full walkthrough; 22/22 pass against the released package with the new template.xml. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MfWFRfRw3sR98Vq48EKEYa --- README.md | 39 +++++ docs/COURSE_SPEC.md | 18 ++- docs/PROJECT_CONTEXT.md | 72 +++++++++ source/data.xml | 26 +-- source/preview.xml | 26 +-- source/scorm_progress.js | 298 ++++++++++++++++++++++++++++++++++- tests/test_scorm_tracking.py | 142 +++++++++++++++++ 7 files changed, 593 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8df54a4..01933cd 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,45 @@ These are binding on this derivative work — see - **Incident callouts** must be self-contained for learners who haven't read the OWASP docs; incident references include hyperlinked sources. +## Progress and completion: what learners and the LMS see + +- **In the course**: a header progress bar (percentage of pages opened, with a + marker per quiz), contents-page ticks that mean *opened* for content pages + and *submitted* for quizzes (an opened-but-unsubmitted quiz shows a + half-filled circle), and a **Your progress** panel on the contents page and + the Course complete page that lists the required pages still to visit, each + quiz result, the weighted score, and the exact status the LMS holds. See + [`docs/PROJECT_CONTEXT.md`](docs/PROJECT_CONTEXT.md#learner-facing-progress-status-panel-honest-quiz-ticks-progress-bar). +- **In the LMS**: SCORM 1.2 carries no progress figure, only + `cmi.core.lesson_status`. The course reports `incomplete` until every + required page (Welcome through the final quiz) has been visited, then + `passed` or `failed` against the 80% mark. Status and score are committed as + the learner goes, so the flip is visible as soon as it happens, but nothing + in between can move an LMS percentage. + +### Moodle settings + +If your LMS is Moodle (the one this course was first deployed to), three +settings on the SCORM activity matter: + +| Setting | Recommended | Why | +| --- | --- | --- | +| **Grading method** | Learning objects (the default) | Grades by counting SCOs whose status is completed/passed. This package is one SCO, so the grade is 0% until the learner passes, then 100%. That is the "0% until the end" learners see, and it is honest. "Highest grade" / "Average grade" would show the running quiz score instead, which reads as 100% after one perfect quiz with 40 pages still to go. | +| **Completion tracking** | Require status: *Passed* (optionally a minimum score) | Makes Moodle's activity completion mean the same thing as the course's own pass logic. Do not use "view" or "grade" alone. | +| **Display attempt status** | Dashboard and entry page | Lets learners see the status and grade Moodle holds without opening the course. | + +Moodle applies no mastery-score override here (the Xerte manifest declares no +`adlcp:masteryscore`), so the status the course sends is the status Moodle +stores. Administrators can see live per-question progress under the activity's +**Reports → Interactions report** and **Track details**, because the course +commits after every page and every answered question. + +Two things that do **not** help with Moodle: exporting as SCORM 2004 (Moodle +stores `cmi.progress_measure` but never displays it as progress, and Xerte's +2004 tracker does not set it anyway) and splitting the course into one SCO per +theme (the only way to get a Moodle-native partial grade, at the cost of eight +Xerte projects, a hand-built manifest and no shared final score). + ## Tests The tracking tests build the SCORM package from `source/data.xml` with a real diff --git a/docs/COURSE_SPEC.md b/docs/COURSE_SPEC.md index 25412ba..2872ac0 100644 --- a/docs/COURSE_SPEC.md +++ b/docs/COURSE_SPEC.md @@ -37,6 +37,11 @@ Model, version 1.2)** package with **LMS (Learning Management System)-reported s - **Scoring**: a **Configure Scores** page sets project scoring; theme quizzes (25%) + final quiz (75%) **all count** toward the LMS score. Final quiz pass mark **80%**. - **SCORM 1.2** export → `cmi.core.score.raw`, `cmi.core.lesson_status`, `cmi.interactions.n`. +- **Progress indicators**: header progress bar (`progressBarType="header2"`, milestones on each + quiz, label "{x}% of pages viewed"); contents-page ticks mean *opened* for content pages and + *submitted* for quizzes; a **Your progress** panel (`xtStatusPanel`, filled by the root + script) on the contents page (`menuText`) and on the Course complete page shows required + pages done/still to visit, quiz results, weighted score and the status reported to the LMS. - **Option order**: every quiz question sets `answerOrder="random"` so the answer options shuffle on each attempt (Nottingham per-question "Answer Order" property). - **Question types used** (all selection-based, no free text): Multiple Choice (single), @@ -68,6 +73,9 @@ Real-world incident callout (where Appendix D has one) · Secure-coding mitigati agentic/AI (Artificial Intelligence) apps (OWASP Agentic Top 10 2026), interleaved by theme. - Structure: 7 themes, a quiz per theme, a final assessment. Time ~3–4 h. - How scoring works: theme quizzes count 25%, final quiz counts 75%; final pass mark 80%. +- Tracking your progress: what the progress bar, the ticks (opened vs. submitted) and the + **Your progress** panel mean; every page from Welcome to the final quiz must be visited and + every quiz submitted for the course to count as complete. ### Page 2 — About, licensing, attributions & provenance [Content] - CC BY-SA 4.0 (Creative Commons Attribution-ShareAlike 4.0) notice + @@ -563,10 +571,12 @@ All framed with full descriptive names (no item codes as the question subject). Security Misconfiguration = Classic; Cascading Failures = Agentic. ### Page 44 — Course complete [Content] -- A closing page so it is unambiguous that the training is over: congratulates the learner, - reminds them their result is recorded by the LMS (Learning Management System) and that the - 80% pass mark / last-attempt rule applies, and tells them they may close the window. Plain - Text page type. +- A closing page so it is unambiguous that the training is over: opens with the **Your + progress** panel (required pages done, quiz results, weighted score, and the status the LMS + currently holds), congratulates the learner, explains that status and score are sent to the + LMS as they go (so an "incomplete" means going back to the contents page for the pages it + lists), that the 80% pass mark / last-attempt rule applies, and tells them they may close the + window. Plain Text page type. ### [Scores] — Configure Scores - Project scoring enabled; theme quizzes (25%) + final quiz (75%) all count; final pass mark diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index fc7404b..9925bc7 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -408,6 +408,78 @@ limit is respected). Because `scoremode` is `last`, re-taking a quiz still overwrites the remembered score, and a page never counted twice (`findPage()` matches the first page-level entry per `page_nr`). +## Learner-facing progress (status panel, honest quiz ticks, progress bar) + +**Symptom**: learners could not tell how far through the course they were or +whether they had done what completion requires. Two things misled them: + +- the contents-page **tick means "opened"**, nothing more. `x_pageLoaded()` + sets `x_pageInfo[i].viewed = true` the moment a page finishes loading and + `XENITH.PAGEMENU.tickViewed()` removes the `notvisited` class from any viewed + page; tracking never feeds into it, so a quiz ticks before a single question + is answered; +- the **LMS shows 0% until the very end**. SCORM 1.2 has no progress field. + `getSuccessStatus()` returns `incomplete` until every one of the 43 tracked + pages has been *left* (after more than 100 ms), then `passed`/`failed` + against the pass mark. Moodle's default SCORM grading method, "Learning + objects", counts SCOs whose status is completed/passed, and this package is + one SCO, so the grade is 0 then 1. See "Moodle settings" in the README. + +The LMS side is structural (see the README for why SCORM 2004 and multi-SCO +were rejected), so the fix is to make the course itself honest. Three parts, +all content-level (no engine patch): + +1. **Status panel** (`source/scorm_progress.js`, part 5). Every element with + the class `xtStatusPanel` is filled with: required pages completed + (`state.completedPages` against `state.toCompletePages`), the names of the + pages still to visit (capped at 6), each quiz page's result or "not yet + submitted", the weighted score so far against `state.lo_passed`, and the + exact status `state.getSuccessStatus()` would report to the LMS, in plain + language. Two placeholders exist: the root `menuText` attribute (shown in + the text column of the contents page) and a `
` at the top of the + **Course complete** page. The panel is refreshed from `x_pageLoaded`, + `XENITH.PAGEMENU.tickViewed`, `XTExitPage`, `XTExitInteraction` and + `XTSetPageScore` (all wrapped). Outside an LMS it says tracking is not + active. `window.xtRefreshProgressUi()` re-renders it on demand. +2. **Quiz ticks mean "submitted"**. The engine never reads the page-level + interaction's `complete` field, so the `XTSetPageScore` wrapper (the quiz + results screen is its only caller) sets it to `true`, and it is persisted as + a sixth element of each `xtPages` row (`1`/`0`) and restored by `setVars`. + Records written before the flag existed have no `row[5]`; a non-zero score + can only come from the results screen, so it is treated as submitted. After + `tickViewed`, the tick of a quiz page that is viewed but not submitted is + swapped for `fa-adjust` (a half-filled circle) with the class `xtPending` + and the label "Opened, quiz not yet submitted"; it reverts on submission. +3. **Header progress bar** (Xerte's own, editor-supported): root attributes + `progressBarType="header2"` ("below titles"; `header1`, "above titles", + is inserted after the header logo icon, and this course has no logo, so + jQuery's `insertAfter` on an empty set silently builds nothing), + `progressSub="milestones"`, + `progressBarPercentage="true"`, `progressBarTxt="{x}% of pages viewed"`, + `progressBarSubLink="true"`, plus `milestone="true"` on each of the 8 quiz + pages. It is viewed-based like the ticks, hence the label wording. + +The Welcome page explains the three indicators; the Course complete page no +longer claims the result "has been recorded" unconditionally and points at the +panel instead. + +**Deliberately not changed**: what "required" means. All 43 tracked pages +(Welcome through the final quiz) still count, including About, Mapping, +Glossary and References. Exempting a page needs care: because of the +off-by-one described under "SCORM completion bug fix", `unmarkForCompletion` +on page *N* actually exempts page *N-1*; the clean route is for the root script +to rebuild `state.toCompletePages` from the live page list before +`XTInitialise`, and the tests' `TRACKED_PAGES` would follow. Also unchanged: +the engine's pass check is `getdScaledScore() > lo_passed / 100`, strictly +greater, so a score of exactly 80% reports `failed` (Moodle applies no mastery +override because the Xerte manifest declares no `adlcp:masteryscore`). + +Tests: `test_status_panel_tells_the_learner_what_is_left`, +`test_quiz_tick_means_submitted_not_opened`, +`test_older_records_infer_submission_from_the_score`, +`test_header_progress_bar_counts_viewed_pages`, and the panel assertions at the +end of `test_full_walkthrough_reports_passed`. + ## Build version stamping (`{{BUILD_VERSION}}` placeholder) So an operator can tell **which version is running** in an LMS, the SCORM diff --git a/source/data.xml b/source/data.xml index 729e210..8c87b74 100644 --- a/source/data.xml +++ b/source/data.xml @@ -1,5 +1,5 @@ -Welcome to Secure code development +Welcome to Secure code development

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

@@ -20,6 +20,10 @@

Expected time: 3–4 hours.

Scoring: theme quizzes count for 25% of the final score, and the final quiz counts for 75%. The pass mark is 80%.

+ +

Tracking your progress

+ +

The progress bar at the top of the page shows how many pages you have opened. On the contents page, a tick means you have opened a page, and a half-filled circle marks a quiz you have opened but not yet submitted. The Your progress panel on the contents page and on the final page lists the pages you still need to visit, your quiz results, and the status your LMS currently holds. Every page from this one through to the final quiz must be visited, and every quiz submitted, for the course to count as complete.

]]>
Licensing

This course is licensed under CC BY-SA 4.0 (Creative Commons Attribution-ShareAlike 4.0). You are free to share and adapt the material, provided you give appropriate credit and share any derivatives under the same license. Full license: https://creativecommons.org/licenses/by-sa/4.0/.

@@ -207,7 +211,7 @@
  • Detect delegated and transitive permissions — model the full delegation chain. If agent A delegates to agent B, the effective permissions should be the intersection of what A and B are each allowed to do.
  • Managed NHI (Non-Human Identity) — place agent identities in a dedicated identity platform such as Entra, AWS Bedrock, or Agentforce. Use lifecycle management, rotation, and audit for non-human accounts just as you would for human employees.
  • -]]>
    This theme traces how untrusted input becomes unintended action. We start with classic injection, then follow the chain into agentic territory: agents whose goals can be hijacked, whose tools can be misused, and whose generated code can execute unexpectedly.

    +]]>
    This theme traces how untrusted input becomes unintended action. We start with classic injection, then follow the chain into agentic territory: agents whose goals can be hijacked, whose tools can be misused, and whose generated code can execute unexpectedly.

    For engineers building agents, the lesson is that input validation, intent checks, and execution boundaries matter at every layer.

    ]]>
    Injection @@ -329,7 +333,7 @@
  • Static scans before execution. Run security scanners and dependency checks on generated code before it is executed.
  • Runtime monitoring. Observe file, network, and process activity during code execution and terminate anomalous behaviour.
  • -]]>
    Theme 3: Supply Chain & Integrity looks at trust in code, data, and context. In classic software, the supply chain is mostly static: libraries, packages, build tools, and the CI/CD (Continuous Integration / Continuous Deployment) pipeline that produces your artefact. If any of those ingredients are tampered with, the final application carries the flaw. The OWASP Top 10:2025 tracks this as Software Supply Chain Failures (A03:2025) and Software or Data Integrity Failures (A08:2025).

    +]]>
    Theme 3: Supply Chain & Integrity looks at trust in code, data, and context. In classic software, the supply chain is mostly static: libraries, packages, build tools, and the CI/CD (Continuous Integration / Continuous Deployment) pipeline that produces your artefact. If any of those ingredients are tampered with, the final application carries the flaw. The OWASP Top 10:2025 tracks this as Software Supply Chain Failures (A03:2025) and Software or Data Integrity Failures (A08:2025).

    Agentic systems extend that same idea into a dynamic, runtime supply chain. An agent does not just import a dependency at build time; it can discover and compose tools, models, prompts, and other agents at runtime. This composition happens through protocols such as the Model Context Protocol (MCP) and Agent-to-Agent (A2A) frameworks, and through registries that advertise tool or agent capabilities. Each extra capability is a dependency that can be malicious, misconfigured, or compromised.

    @@ -492,7 +496,7 @@
  • Decay / expire unverified memory. Reduce the trust weight of unverified entries over time and eventually remove them.
  • Trust-weighted retrieval. Rank search results by source trust and confidence, not just embedding similarity.
  • -]]>
    Theme 4: Cryptographic Failures & Secrets +]]>Theme 4: Cryptographic Failures & Secrets

    Theme 4 explores Cryptographic Failures (A04:2025) from the OWASP Top 10:2025, plus what those failures mean for agents and Non-Human Identities (NHI).

    @@ -535,7 +539,7 @@
  • Private keys should never be directly accessible to agents. Use an orchestrator or trusted signer to perform operations on behalf of the agent.
  • Treat Non-Human Identity (NHI) credentials as a full lifecycle asset: issue with least privilege, monitor use, rotate frequently, and revoke immediately when an agent is decommissioned or suspected of compromise.
  • -]]>
    Theme 5: Configuration, Design & Resilience +]]>Theme 5: Configuration, Design & Resilience

    Theme 5 brings together three overlapping risks from the OWASP Top 10:2025 — Security Misconfiguration (A02:2025), Insecure Design (A06:2025), and Mishandling of Exceptional Conditions (A10:2025) — and adds the agentic risk of Cascading Failures (ASI08).

    @@ -649,7 +653,7 @@
  • Use digital-twin replay and policy gating to test risky decisions before live execution.
  • Keep tamper-evident logging and non-repudiation so every propagated action can be traced and audited.
  • -]]>
    Theme 6 covers the risks that are unique to agentic systems, where software does not just respond to a single user but can act on its own, talk to other agents, and influence the people around it.

    +]]>
    Theme 6 covers the risks that are unique to agentic systems, where software does not just respond to a single user but can act on its own, talk to other agents, and influence the people around it.

    Where earlier themes mapped classic application risks to the agent world, this theme is different: it focuses on three risks that only appear when multiple agents, or a human and an agent, share responsibility.

    @@ -751,7 +755,7 @@
  • Require periodic behavioural attestation and per-run ephemeral credentials, so compromise is time-bound.
  • Tie recovery or reintegration to fresh attestation and explicit human approval.
  • -]]>
    Theme 7 brings the focus onto observability, logging and detection. These controls are cross-cutting: they support almost every other theme in this course.

    +]]>
    Theme 7 brings the focus onto observability, logging and detection. These controls are cross-cutting: they support almost every other theme in this course.

    For traditional applications, good logging and alerting helps you detect breaches, investigate incidents and prove compliance. For agentic systems, the need is even stronger. Agents make sequences of decisions, call tools, talk to other agents and influence humans. Without a clear, tamper-evident record of what happened, you cannot attribute actions, detect drift or recover from failure.

    @@ -785,7 +789,7 @@

    For agents, the same principles apply, but the events are different. You should log tool calls, inter-agent messages, goal state and action sequences against a baseline. Track a stable goal identifier across the lifetime of a task so that every action can be traced back to the original request. Alert on goal drift and anomalous tool chains, and ensure non-repudiation and lineage for every propagated action.

    Strong observability turns an opaque agent into an accountable one: it is not enough to know what the agent said; you must be able to reconstruct what it did.

    -]]>
    Putting it together: secure SDLC and threat modelling +]]>Putting it together: secure SDLC and threat modelling

    Secure code development is not a single step; it is threaded through the whole SDLC (Software Development Life Cycle). The two OWASP lists in this course — the OWASP Top 10:2025 for classic applications and the OWASP Top 10 for Agentic Applications 2026 — give you concrete risks to address at each phase.

    @@ -986,7 +990,9 @@

    License of this course

    This course is licensed under CC BY-SA 4.0 (Creative Commons Attribution-ShareAlike 4.0). It is an adapted summary/derivative; changes were made; it is not endorsed by OWASP. Generated with the assistance of an AI coding agent.

    -]]>
    Course complete +]]>Course complete + +

    You have reached the end of the Secure code development course — thank you for completing it.

    @@ -995,7 +1001,7 @@

    What happens next

      -
    • Your result has been recorded by the LMS (Learning Management System). If you did not reach the 80% pass mark, you can revisit the themes and retake the quizzes — your last attempt is the one that counts.
    • +
    • The Your progress panel above shows exactly what the LMS (Learning Management System) currently holds for you. Your status and score are sent to the LMS as you go, so if it still says incomplete, return to the contents page and open the pages it lists. If you did not reach the 80% pass mark, you can revisit the themes and retake the quizzes — your last attempt is the one that counts.
    • Keep the OWASP Top 10:2025 and the OWASP Top 10 for Agentic Applications 2026 to hand when you design, review or build agents. The checklists and threat-modelling steps in this course are a starting point, not a finish line.
    • Share feedback or suggest improvements via the course repository.
    diff --git a/source/preview.xml b/source/preview.xml index 729e210..8c87b74 100644 --- a/source/preview.xml +++ b/source/preview.xml @@ -1,5 +1,5 @@ -Welcome to Secure code development +Welcome to Secure code development

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

    @@ -20,6 +20,10 @@

    Expected time: 3–4 hours.

    Scoring: theme quizzes count for 25% of the final score, and the final quiz counts for 75%. The pass mark is 80%.

    + +

    Tracking your progress

    + +

    The progress bar at the top of the page shows how many pages you have opened. On the contents page, a tick means you have opened a page, and a half-filled circle marks a quiz you have opened but not yet submitted. The Your progress panel on the contents page and on the final page lists the pages you still need to visit, your quiz results, and the status your LMS currently holds. Every page from this one through to the final quiz must be visited, and every quiz submitted, for the course to count as complete.

    ]]>
    Licensing

    This course is licensed under CC BY-SA 4.0 (Creative Commons Attribution-ShareAlike 4.0). You are free to share and adapt the material, provided you give appropriate credit and share any derivatives under the same license. Full license: https://creativecommons.org/licenses/by-sa/4.0/.

    @@ -207,7 +211,7 @@
  • Detect delegated and transitive permissions — model the full delegation chain. If agent A delegates to agent B, the effective permissions should be the intersection of what A and B are each allowed to do.
  • Managed NHI (Non-Human Identity) — place agent identities in a dedicated identity platform such as Entra, AWS Bedrock, or Agentforce. Use lifecycle management, rotation, and audit for non-human accounts just as you would for human employees.
  • -]]>
    This theme traces how untrusted input becomes unintended action. We start with classic injection, then follow the chain into agentic territory: agents whose goals can be hijacked, whose tools can be misused, and whose generated code can execute unexpectedly.

    +]]>
    This theme traces how untrusted input becomes unintended action. We start with classic injection, then follow the chain into agentic territory: agents whose goals can be hijacked, whose tools can be misused, and whose generated code can execute unexpectedly.

    For engineers building agents, the lesson is that input validation, intent checks, and execution boundaries matter at every layer.

    ]]>
    Injection @@ -329,7 +333,7 @@
  • Static scans before execution. Run security scanners and dependency checks on generated code before it is executed.
  • Runtime monitoring. Observe file, network, and process activity during code execution and terminate anomalous behaviour.
  • -]]>
    Theme 3: Supply Chain & Integrity looks at trust in code, data, and context. In classic software, the supply chain is mostly static: libraries, packages, build tools, and the CI/CD (Continuous Integration / Continuous Deployment) pipeline that produces your artefact. If any of those ingredients are tampered with, the final application carries the flaw. The OWASP Top 10:2025 tracks this as Software Supply Chain Failures (A03:2025) and Software or Data Integrity Failures (A08:2025).

    +]]>
    Theme 3: Supply Chain & Integrity looks at trust in code, data, and context. In classic software, the supply chain is mostly static: libraries, packages, build tools, and the CI/CD (Continuous Integration / Continuous Deployment) pipeline that produces your artefact. If any of those ingredients are tampered with, the final application carries the flaw. The OWASP Top 10:2025 tracks this as Software Supply Chain Failures (A03:2025) and Software or Data Integrity Failures (A08:2025).

    Agentic systems extend that same idea into a dynamic, runtime supply chain. An agent does not just import a dependency at build time; it can discover and compose tools, models, prompts, and other agents at runtime. This composition happens through protocols such as the Model Context Protocol (MCP) and Agent-to-Agent (A2A) frameworks, and through registries that advertise tool or agent capabilities. Each extra capability is a dependency that can be malicious, misconfigured, or compromised.

    @@ -492,7 +496,7 @@
  • Decay / expire unverified memory. Reduce the trust weight of unverified entries over time and eventually remove them.
  • Trust-weighted retrieval. Rank search results by source trust and confidence, not just embedding similarity.
  • -]]>
    Theme 4: Cryptographic Failures & Secrets +]]>Theme 4: Cryptographic Failures & Secrets

    Theme 4 explores Cryptographic Failures (A04:2025) from the OWASP Top 10:2025, plus what those failures mean for agents and Non-Human Identities (NHI).

    @@ -535,7 +539,7 @@
  • Private keys should never be directly accessible to agents. Use an orchestrator or trusted signer to perform operations on behalf of the agent.
  • Treat Non-Human Identity (NHI) credentials as a full lifecycle asset: issue with least privilege, monitor use, rotate frequently, and revoke immediately when an agent is decommissioned or suspected of compromise.
  • -]]>
    Theme 5: Configuration, Design & Resilience +]]>Theme 5: Configuration, Design & Resilience

    Theme 5 brings together three overlapping risks from the OWASP Top 10:2025 — Security Misconfiguration (A02:2025), Insecure Design (A06:2025), and Mishandling of Exceptional Conditions (A10:2025) — and adds the agentic risk of Cascading Failures (ASI08).

    @@ -649,7 +653,7 @@
  • Use digital-twin replay and policy gating to test risky decisions before live execution.
  • Keep tamper-evident logging and non-repudiation so every propagated action can be traced and audited.
  • -]]>
    Theme 6 covers the risks that are unique to agentic systems, where software does not just respond to a single user but can act on its own, talk to other agents, and influence the people around it.

    +]]>
    Theme 6 covers the risks that are unique to agentic systems, where software does not just respond to a single user but can act on its own, talk to other agents, and influence the people around it.

    Where earlier themes mapped classic application risks to the agent world, this theme is different: it focuses on three risks that only appear when multiple agents, or a human and an agent, share responsibility.

    @@ -751,7 +755,7 @@
  • Require periodic behavioural attestation and per-run ephemeral credentials, so compromise is time-bound.
  • Tie recovery or reintegration to fresh attestation and explicit human approval.
  • -]]>
    Theme 7 brings the focus onto observability, logging and detection. These controls are cross-cutting: they support almost every other theme in this course.

    +]]>
    Theme 7 brings the focus onto observability, logging and detection. These controls are cross-cutting: they support almost every other theme in this course.

    For traditional applications, good logging and alerting helps you detect breaches, investigate incidents and prove compliance. For agentic systems, the need is even stronger. Agents make sequences of decisions, call tools, talk to other agents and influence humans. Without a clear, tamper-evident record of what happened, you cannot attribute actions, detect drift or recover from failure.

    @@ -785,7 +789,7 @@

    For agents, the same principles apply, but the events are different. You should log tool calls, inter-agent messages, goal state and action sequences against a baseline. Track a stable goal identifier across the lifetime of a task so that every action can be traced back to the original request. Alert on goal drift and anomalous tool chains, and ensure non-repudiation and lineage for every propagated action.

    Strong observability turns an opaque agent into an accountable one: it is not enough to know what the agent said; you must be able to reconstruct what it did.

    -]]>
    Putting it together: secure SDLC and threat modelling +]]>Putting it together: secure SDLC and threat modelling

    Secure code development is not a single step; it is threaded through the whole SDLC (Software Development Life Cycle). The two OWASP lists in this course — the OWASP Top 10:2025 for classic applications and the OWASP Top 10 for Agentic Applications 2026 — give you concrete risks to address at each phase.

    @@ -986,7 +990,9 @@

    License of this course

    This course is licensed under CC BY-SA 4.0 (Creative Commons Attribution-ShareAlike 4.0). It is an adapted summary/derivative; changes were made; it is not endorsed by OWASP. Generated with the assistance of an AI coding agent.

    -]]>
    Course complete +]]>Course complete + +

    You have reached the end of the Secure code development course — thank you for completing it.

    @@ -995,7 +1001,7 @@

    What happens next

      -
    • Your result has been recorded by the LMS (Learning Management System). If you did not reach the 80% pass mark, you can revisit the themes and retake the quizzes — your last attempt is the one that counts.
    • +
    • The Your progress panel above shows exactly what the LMS (Learning Management System) currently holds for you. Your status and score are sent to the LMS as you go, so if it still says incomplete, return to the contents page and open the pages it lists. If you did not reach the 80% pass mark, you can revisit the themes and retake the quizzes — your last attempt is the one that counts.
    • Keep the OWASP Top 10:2025 and the OWASP Top 10 for Agentic Applications 2026 to hand when you design, review or build agents. The checklists and threat-modelling steps in this course are a starting point, not a finish line.
    • Share feedback or suggest improvements via the course repository.
    diff --git a/source/scorm_progress.js b/source/scorm_progress.js index 3104ec5..952a4b5 100644 --- a/source/scorm_progress.js +++ b/source/scorm_progress.js @@ -60,6 +60,20 @@ * LMS as the learner goes, because the engine only calls doLMSCommit() from * finishTracking(), i.e. on Save Session / unload. * + * Finally it gives the learner an honest view of that tracking state, which + * the engine does not: the table-of-contents tick only means a page has been + * opened (x_pageLoaded sets x_pageInfo[i].viewed), and SCORM 1.2 gives the + * LMS nothing but the incomplete/passed/failed flag, so Moodle shows 0% until + * the very end. So this script + * + * 5. marks a quiz page as "submitted" (page interaction .complete, a field + * the engine never reads) when the quiz reports its score, persists that + * flag in xtPages, swaps the menu tick of an opened-but-unsubmitted quiz + * for a half-filled circle, and fills every element with the class + * xtStatusPanel (one is in the menu text, one on the Course complete + * page) with the required-page count, the pages still to do, each quiz + * result, the weighted score and the exact status the LMS holds. + * * CONSTRAINT: the compiled one-liner is stored raw in an XML attribute, so it * must not contain < > & or " characters (tools/sync_root_script.py enforces * this). Use === / !== / indexOf() instead of ordering comparisons, nested ifs @@ -129,7 +143,7 @@ /* A page-level interaction shaped the way ScormInteractionTracking.setVars expects, used to put a remembered quiz page score back into state. */ - function pageEntry(pageNr, score, weighting, nrinteractions, id) { + function pageEntry(pageNr, score, weighting, nrinteractions, id, complete) { var now = (new Date()).toISOString(); return { page_nr: pageNr, @@ -147,7 +161,7 @@ weighting: weighting, score: score, result: 'unknown', - complete: false, + complete: complete === true, correctOptions: [], correctAnswers: [], correctfeedback: '', @@ -191,6 +205,14 @@ return list.map(function (it) { return it.id; }); } + /* Was this remembered quiz page submitted? Records written before the + flag existed have no row[5]; a non-zero score can only come from the + results screen, so treat it as submitted. */ + function submittedRow(row) { + if (row[5] === 1) { return true; } + return round2(row[1]) !== 0; + } + /* completedPages must line up with the pages this build asks for. */ function normaliseCompleted(saved) { var out = []; @@ -239,7 +261,7 @@ if (pages.indexOf(row[0]) !== -1) { return; } if (!livePage(row[0])) { return; } pages.push(row[0]); - kept.push(pageEntry(row[0], round2(row[1]), Number(row[2]), Number(row[3]), row[4])); + kept.push(pageEntry(row[0], round2(row[1]), Number(row[2]), Number(row[3]), row[4], submittedRow(row))); }); } delete obj.xtPages; @@ -293,7 +315,7 @@ st.interactions.forEach(function (it) { if (!usable(it)) { return; } if (!scored(it)) { return; } - rows.push([it.page_nr, round2(it.score), Number(it.weighting), Number(it.nrinteractions), it.id]); + rows.push([it.page_nr, round2(it.score), Number(it.weighting), Number(it.nrinteractions), it.id, it.complete === true ? 1 : 0]); }); return rows; } @@ -381,6 +403,270 @@ window.xPersistProgress = persist; + /* ---- learner-facing progress: quiz ticks and the status panel ---- */ + + var PENDING_LABEL = 'Opened, quiz not yet submitted'; + var VIEWED_LABEL = 'Viewed'; + var LIST_CAP = 6; + + function trackingActive() { + if (typeof state === 'undefined') { return false; } + if (!state) { return false; } + if (!state.initialised) { return false; } + if (typeof state.scormmode === 'string') { + if (state.scormmode !== 'normal') { return false; } + } + if (typeof state.getSuccessStatus !== 'function') { return false; } + if (typeof state.findPage !== 'function') { return false; } + if (!isArray(state.toCompletePages)) { return false; } + return state.toCompletePages.length !== 0; + } + + function pageType(idx) { + if (!livePage(idx)) { return ''; } + return String(x_pageInfo[idx].type); + } + + function pageViewed(idx) { + if (!livePage(idx)) { return false; } + return x_pageInfo[idx].viewed === true; + } + + /* page names are stored HTML-escaped; render them as text */ + function pageName(idx) { + var raw = ''; + if (typeof x_pages !== 'undefined') { + if (x_pages) { + if (x_pages[idx]) { + if (typeof x_pages[idx].getAttribute === 'function') { + raw = x_pages[idx].getAttribute('name') || ''; + } + } + } + } + var holder = document.createElement('div'); + holder.innerHTML = raw; + return holder.textContent || raw; + } + + function quizPages() { + var out = []; + if (!pagesKnown()) { return out; } + x_pageInfo.forEach(function (info, idx) { + if (pageType(idx) === 'quiz') { out.push(idx); } + }); + return out; + } + + /* A quiz counts as submitted once XTSetPageScore has run for it (the + results screen), in this session or a remembered one. */ + function quizSubmitted(idx) { + if (!trackingActive()) { return false; } + var sit = state.findPage(idx); + if (!sit) { return false; } + return sit.complete === true; + } + + function quizPending(idx) { + if (pageType(idx) !== 'quiz') { return false; } + if (!pageViewed(idx)) { return false; } + return !quizSubmitted(idx); + } + + function quizScore(idx) { + var sit = state.findPage(idx); + if (!sit) { return 0; } + return Math.round(Number(sit.score)); + } + + function markSubmitted(pageNr) { + if (!trackingActive()) { return; } + var sit = state.findPage(pageNr); + if (sit) { sit.complete = true; } + } + + function menuOffset() { + if (typeof XENITH === 'undefined') { return 0; } + if (!XENITH.PAGEMENU) { return 0; } + return XENITH.PAGEMENU.menuPage === true ? 1 : 0; + } + + function setPending(icon, pending) { + if (pending) { + if (icon.classList.contains('xtPending')) { return; } + icon.classList.add('xtPending'); + icon.classList.add('fa-adjust'); + icon.classList.remove('fa-x-tick-circle'); + icon.setAttribute('aria-label', PENDING_LABEL); + icon.setAttribute('title', PENDING_LABEL); + icon.setAttribute('aria-hidden', 'false'); + return; + } + if (!icon.classList.contains('xtPending')) { return; } + icon.classList.remove('xtPending'); + icon.classList.remove('fa-adjust'); + icon.classList.add('fa-x-tick-circle'); + icon.setAttribute('aria-label', VIEWED_LABEL); + icon.removeAttribute('title'); + } + + /* Runs after XENITH.PAGEMENU.tickViewed: every table of contents in the + DOM (menu page, sidebar, dialog) gets its quiz ticks corrected. */ + function fixTicks() { + if (typeof jQuery !== 'function') { return; } + if (typeof x_normalPages === 'undefined') { return; } + if (!isArray(x_normalPages)) { return; } + var offset = menuOffset(); + jQuery('.menuItem').each(function () { + var pos = jQuery(this).data('pageIndex'); + if (typeof pos !== 'number') { return; } + var idx = x_normalPages[pos + offset]; + if (typeof idx !== 'number') { return; } + var icon = this.querySelector('i.viewTick'); + if (!icon) { return; } + setPending(icon, quizPending(idx)); + }); + } + + function el(tag, text) { + var node = document.createElement(tag); + if (text !== undefined) { node.textContent = text; } + return node; + } + + function joinNames(list) { + var shown = list.slice(0, LIST_CAP); + var rest = list.slice(LIST_CAP).length; + var text = shown.join('; '); + if (rest !== 0) { text = text + '; and ' + rest + ' more'; } + return text; + } + + function requiredSummary() { + var done = 0; + var missing = []; + state.toCompletePages.forEach(function (pageNr, j) { + if (state.completedPages[j] === true) { done = done + 1; } + else { missing.push(pageName(pageNr)); } + }); + return { done: done, total: state.toCompletePages.length, missing: missing }; + } + + function statusText(status, score, passMark, unsubmitted) { + if (status === 'passed') { + return 'Passed. The LMS has been told you passed this course with ' + score + '%.'; + } + if (status === 'failed') { + return 'Not passed. Every required page is done, but your score of ' + score + '% is below the ' + passMark + '% pass mark. Retake the quizzes; your last attempt is the one that counts.'; + } + if (status === 'completed') { + return 'Completed without a score. Every required page is done but no quiz has been submitted, so the LMS has no result for you. Submit the quizzes to be graded.'; + } + var text = 'Incomplete. The LMS shows this course as incomplete (0%) until every required page has been visited'; + if (unsubmitted !== 0) { text = text + ' and every quiz submitted'; } + return text + '. Use the list above to find what is left.'; + } + + function buildPanel(panel) { + while (panel.firstChild) { panel.removeChild(panel.firstChild); } + panel.appendChild(el('h3', 'Your progress')); + if (!trackingActive()) { + panel.appendChild(el('p', 'Progress tracking is not active in this view. Launch the course from your LMS to have your progress recorded.')); + return; + } + var req = requiredSummary(); + var pages = el('p'); + pages.appendChild(el('strong', 'Required pages completed: ' + req.done + ' of ' + req.total + '.')); + if (req.missing.length === 0) { + pages.appendChild(document.createTextNode(' Every required page has been visited.')); + } else { + pages.appendChild(document.createTextNode(' Still to visit: ' + joinNames(req.missing) + '. A page counts once you have opened it and moved on.')); + } + panel.appendChild(pages); + var quizzes = quizPages(); + var unsubmitted = 0; + if (quizzes.length !== 0) { + var list = el('ul'); + quizzes.forEach(function (idx) { + var item = el('li'); + var name = pageName(idx); + if (quizSubmitted(idx)) { + item.textContent = name + ': ' + quizScore(idx) + '%'; + } else { + unsubmitted = unsubmitted + 1; + item.textContent = name + ': not yet submitted'; + item.className = 'xtQuizPending'; + } + list.appendChild(item); + }); + panel.appendChild(list); + } + var passMark = Math.round(Number(state.lo_passed)); + var score = Math.round(Number(state.getRawScore())); + panel.appendChild(el('p', 'Weighted score so far: ' + score + '%. Pass mark: ' + passMark + '%. Theme quizzes count for 25% and the final quiz for 75%.')); + var status = el('p'); + status.className = 'xtLmsStatus'; + status.setAttribute('data-status', state.getSuccessStatus()); + status.appendChild(el('strong', 'LMS status: ')); + status.appendChild(document.createTextNode(statusText(state.getSuccessStatus(), score, passMark, unsubmitted))); + panel.appendChild(status); + } + + function fillPanels() { + var panels = document.querySelectorAll('.xtStatusPanel'); + var i = 0; + for (i = 0; i !== panels.length; i = i + 1) { + panels[i].style.border = '1px solid currentColor'; + panels[i].style.borderRadius = '6px'; + panels[i].style.padding = '0.5em 1em'; + panels[i].style.margin = '0 0 1em 0'; + buildPanel(panels[i]); + } + } + + function refreshUi() { + try { fixTicks(); } catch (e) { } + try { fillPanels(); } catch (e) { } + } + + window.xtRefreshProgressUi = refreshUi; + + function wrapTickViewed() { + if (typeof XENITH === 'undefined') { return; } + if (!XENITH.PAGEMENU) { return; } + if (typeof XENITH.PAGEMENU.tickViewed !== 'function') { return; } + var orig = XENITH.PAGEMENU.tickViewed; + XENITH.PAGEMENU.tickViewed = function () { + var result = orig.apply(this, arguments); + refreshUi(); + return result; + }; + } + + /* every page model calls x_pageLoaded once its content is in the DOM */ + function wrapPageLoaded() { + if (typeof window.x_pageLoaded !== 'function') { return; } + var orig = window.x_pageLoaded; + window.x_pageLoaded = function () { + var result = orig.apply(this, arguments); + refreshUi(); + return result; + }; + } + + /* the quiz results screen is the only caller of XTSetPageScore */ + function wrapPageScore() { + if (typeof window.XTSetPageScore !== 'function') { return; } + var orig = window.XTSetPageScore; + window.XTSetPageScore = function (pageNr, score) { + var result = orig.apply(this, arguments); + try { markSubmitted(pageNr); } catch (e) { } + persist(); + refreshUi(); + return result; + }; + } + /* ---- installation ---- */ function resetResume(st) { @@ -435,6 +721,7 @@ window[name] = function () { var result = orig.apply(this, arguments); persist(); + refreshUi(); return result; }; } @@ -464,6 +751,9 @@ /* must happen before XTInitialise calls initTracking */ wrapState(); wrapTerminate(); + wrapTickViewed(); + wrapPageLoaded(); + wrapPageScore(); if (typeof window.addEventListener === 'function') { window.addEventListener('pagehide', persist); } diff --git a/tests/test_scorm_tracking.py b/tests/test_scorm_tracking.py index f12251f..56d0e55 100644 --- a/tests/test_scorm_tracking.py +++ b/tests/test_scorm_tracking.py @@ -356,6 +356,34 @@ def parse_suspends(raw: list[str]) -> list[dict]: return out +def panel_text(s: Session, page_nr: int) -> str: + """The 'Your progress' panel rendered on the given (already built) page.""" + return s.sco.inner_text(f"#x_page{page_nr} .xtStatusPanel") + + +def lms_status_shown(s: Session, page_nr: int) -> str: + return s.sco.get_attribute(f"#x_page{page_nr} .xtLmsStatus", "data-status") + + +PENDING_QUIZ_TICKS = """ +() => { + const out = []; + const offset = XENITH.PAGEMENU.menuPage ? 1 : 0; + window.jQuery('#x_page0 .menuItem').each(function () { + if (window.jQuery(this).find('i.xtPending').length) { + out.push(x_normalPages[window.jQuery(this).data('pageIndex') + offset]); + } + }); + return out; +} +""" + + +def pending_quiz_ticks(s: Session) -> list[int]: + """Page indexes whose contents-page tick is the 'quiz not yet submitted' marker.""" + return s.sco.evaluate(PENDING_QUIZ_TICKS) + + def assert_resumable(record: dict, where: str) -> None: interactions = record.get("interactions") assert isinstance(interactions, list), f"{where}: interactions is not a list" @@ -395,6 +423,14 @@ def test_package_sanity(h: Harness): ) assert 'unmarkForCompletion="true"' in template, "Welcome page is no longer unmarked" assert "xPersistProgress" in template, "root progress script is missing" + assert 'progressBarType="header2"' in template, "header progress bar is not enabled" + assert template.count('milestone="true"') == len(QUIZ_PAGES), ( + "every quiz page should be a progress-bar milestone" + ) + assert template.count("xtStatusPanel") >= 3, ( + "expected the status panel placeholder in the menu text, on the Course " + "complete page and in the script" + ) compiled = (ROOT / "source" / "scorm_progress.js") if compiled.exists(): # the attribute in the package must be the compiled source, not a hand-edit @@ -821,6 +857,12 @@ def test_full_walkthrough_reports_passed(h: Harness): ) assert s.status() == "passed", f"lesson status is {s.status()!r}, expected 'passed'" assert s.raw_score() == 100, f"score.raw is {s.raw_score()}, expected 100" + # the Course complete page reports the same thing the LMS was told + shown = panel_text(s, LAST_CONTENT_PAGE) + assert f"Required pages completed: {TRACKED_PAGES} of {TRACKED_PAGES}" in shown, shown + assert "LMS status: Passed" in shown, shown + assert lms_status_shown(s, LAST_CONTENT_PAGE) == "passed" + assert "not yet submitted" not in shown, shown s.terminate() assert s.cmi("cmi.core.lesson_status") == "passed", ( f"the LMS was told lesson_status={s.cmi('cmi.core.lesson_status')!r}" @@ -879,6 +921,106 @@ def test_completion_survives_a_resume(h: Harness): # -------------------------------------------------------------------------- +@test +def test_status_panel_tells_the_learner_what_is_left(h: Harness): + """The contents page shows required-page progress and the status the LMS holds. + + Reported problem: learners could not tell how far through the course they + were or whether they had done what completion requires. Ticks only meant + "opened" and SCORM 1.2 gives the LMS nothing but incomplete/passed/failed. + """ + with h.session() as s: + s.open() + shown = panel_text(s, MENU_PAGE) + assert f"Required pages completed: 0 of {TRACKED_PAGES}" in shown, shown + assert "LMS status: Incomplete" in shown, shown + assert lms_status_shown(s, MENU_PAGE) == "incomplete" + assert "Theme 1 Quiz: not yet submitted" in shown, shown + assert "Final comprehensive quiz: not yet submitted" in shown, shown + assert "Pass mark: 80%" in shown, shown + s.goto(FIRST_CONTENT_PAGE) + s.goto(FIRST_CONTENT_PAGE + 1) + s.goto(MENU_PAGE) + # both pages were left after more than 100ms, so both count + shown = panel_text(s, MENU_PAGE) + assert f"Required pages completed: 2 of {TRACKED_PAGES}" in shown, shown + # the pages still owed are named, decoded from their XML names + assert "Still to visit:" in shown, shown + assert "Welcome" not in shown.split("Still to visit:")[1], shown + assert "Theme 1: Access Control, Authentication & Identity" in shown, shown + assert "and " in shown and " more" in shown, f"long list was not truncated: {shown}" + assert not s.page_errors, f"page errors: {s.page_errors}" + + +@test +def test_quiz_tick_means_submitted_not_opened(h: Harness): + """A quiz that has been opened but not submitted gets a distinct marker. + + The engine ticks a page the moment it loads. For quiz pages that is + misleading, so the tick is replaced until the quiz reports its score, and + the 'submitted' flag has to survive a resume like the score does. + """ + with h.session() as s: + s.open() + assert pending_quiz_ticks(s) == [], "nothing has been opened yet" + s.goto(THEME1_QUIZ) + s.goto(MENU_PAGE) + assert pending_quiz_ticks(s) == [THEME1_QUIZ], ( + f"an opened, unsubmitted quiz should be marked pending: {pending_quiz_ticks(s)}" + ) + assert "Theme 1 Quiz: not yet submitted" in panel_text(s, MENU_PAGE) + s.goto(THEME1_QUIZ) + s.answer_quiz() + s.goto(MENU_PAGE) + assert pending_quiz_ticks(s) == [], "a submitted quiz should have a normal tick" + assert "Theme 1 Quiz: 100%" in panel_text(s, MENU_PAGE), panel_text(s, MENU_PAGE) + s.terminate() + saved = s.suspends()[-1] + rows = {row[0]: row for row in json.loads(saved)["xtPages"]} + assert rows[THEME1_QUIZ][5] == 1, f"submitted flag not persisted: {rows[THEME1_QUIZ]}" + assert not s.page_errors, f"page errors: {s.page_errors}" + with h.resumed(saved) as s: + s.open() + s.goto(MENU_PAGE) + assert pending_quiz_ticks(s) == [], "the submitted flag was lost on resume" + assert "Theme 1 Quiz: 100%" in panel_text(s, MENU_PAGE), panel_text(s, MENU_PAGE) + assert not s.page_errors, f"page errors: {s.page_errors}" + + +@test +def test_older_records_infer_submission_from_the_score(h: Harness): + """A record written before the submitted flag existed still shows a scored quiz as done.""" + record = corrupt_record() + record_obj = json.loads(record) + record_obj["interactions"] = [] + # five-element row, as written by builds before the submitted flag existed + record_obj["xtPages"] = [[THEME1_QUIZ, 75, 1, 4, f"urn:x-xerte:p-{THEME1_QUIZ + 1}"]] + record_obj["pagesViewed"] = [0, THEME1_QUIZ] + with h.resumed(json.dumps(record_obj)) as s: + s.open() + s.goto(MENU_PAGE) + assert THEME1_QUIZ not in pending_quiz_ticks(s), "a scored quiz was shown as unsubmitted" + assert "Theme 1 Quiz: 75%" in panel_text(s, MENU_PAGE), panel_text(s, MENU_PAGE) + + +@test +def test_header_progress_bar_counts_viewed_pages(h: Harness): + """The header progress bar is on, labelled honestly, with a marker per quiz.""" + with h.session() as s: + s.open() + assert s.sco.locator("#x_headerProgress").count() == 1, "no header progress bar" + label = s.sco.inner_text("#x_headerProgress .pbTxt") + assert label.startswith("0%") and "of pages viewed" in label, label + assert s.sco.locator("#x_headerProgress .progressMarker").count() == len(QUIZ_PAGES), ( + "expected one progress marker per quiz page" + ) + s.goto(FIRST_CONTENT_PAGE) + s.goto(FIRST_CONTENT_PAGE + 1) + label = s.sco.inner_text("#x_headerProgress .pbTxt") + assert not label.startswith("0%"), f"progress bar did not move: {label}" + assert not s.page_errors, f"page errors: {s.page_errors}" + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("package", type=Path, help="exported SCORM 1.2 zip")