Test a SCORM package automatically with Playwright (for CI and AI coding agents)
Updated 26 October 2026
Quick answer
A short Playwright test can play a SCORM course against a real SCORM runtime standing in for the LMS, and fail when the course stops reporting. It is the check an AI coding agent can run after every change:
- Unzip the course and install two packages: Playwright and scorm-again.
- Copy the test, and replace its learner steps with your course's own.
- Run it: it fails on a missed start or finish, a refused value, the wrong status or score, a missing file, a request to another site or a JavaScript error.
Before you upload, still drop the zip into Alder (free): it plays the course the same way and also shows what an LMS would record.
Set it up
Unzip the course into a folder
Put the package's files in
course/in your project, withimsmanifest.xmlat its top. If you build the course yourself, point the test at the folder you zip.Install Playwright and scorm-again
npm install --save-dev @playwright/test scorm-again, thennpx playwright install chromium. scorm-again is a free, open-source SCORM runtime (the one in Alder's test player). It answers like an LMS, so it refuses values a real LMS would refuse, which a hand-written stub doesn't.Save the test and run it
Save the file below as
tests/course.spec.ts, setLAUNCHto thehrefin the manifest, and runnpx playwright test.
The test
import { expect, test, type Page } from '@playwright/test';
import { existsSync } from 'node:fs';
import path from 'node:path';
// The unzipped package, and the page its imsmanifest.xml launches (<resource href="...">).
const COURSE_DIR = path.resolve('course');
const LAUNCH = 'index.html';
// A real SCORM 1.2 runtime plays the LMS: npm install --save-dev scorm-again
const RUNTIME = path.resolve('node_modules/scorm-again/dist/scorm12.min.js');
// A made-up address, so the course runs over http as in an LMS, not from file://.
const ORIGIN = 'http://course.test';
type Call = { fn: string; args: string[]; result: string; error: string };
async function openCourse(page: Page) {
const problems: string[] = [];
page.on('pageerror', (e) => problems.push(`JavaScript error: ${e.message}`));
// Anything from another site: blocked and reported, as some LMSs and networks would.
await page.route(/^https?:/, (route) => {
const url = route.request().url();
if (url.startsWith(ORIGIN)) return route.fallback();
problems.push(`Outside request: ${url}`);
return route.abort();
});
await page.route(`${ORIGIN}/**`, (route) => {
const file = decodeURIComponent(new URL(route.request().url()).pathname);
if (file === '/__lms.html') {
// The pretend LMS page: it holds the API and shows the course in a frame.
return route.fulfill({
contentType: 'text/html',
body: `<iframe src="/${LAUNCH}" style="width:100%;height:95vh;border:0"></iframe>`,
});
}
const full = path.join(COURSE_DIR, file);
if (existsSync(full)) return route.fulfill({ path: full });
problems.push(`Missing file: ${file}`);
return route.fulfill({ status: 404 });
});
await page.addInitScript({ path: RUNTIME });
await page.addInitScript(() => {
if (window !== window.top) return; // the API lives on the LMS page only
const w = window as any;
const api = new w.Scorm12API({});
w.__calls = [];
for (const fn of ['LMSInitialize', 'LMSSetValue', 'LMSCommit', 'LMSFinish']) {
const original = api[fn].bind(api);
api[fn] = (...args: string[]) => {
const result = original(...args);
w.__calls.push({ fn, args, result, error: api.LMSGetLastError() });
return result;
};
}
w.API = api;
});
await page.goto(`${ORIGIN}/__lms.html`);
return { course: page.frameLocator('iframe'), problems };
}
// Every call so far. Closing the course first lets it finish, as when a learner closes the tab.
async function callsAfterClosing(page: Page): Promise<Call[]> {
await page.evaluate(() => document.querySelector('iframe')?.remove());
return page.evaluate(() => (window as any).__calls);
}
const lastSet = (calls: Call[], element: string) =>
calls.filter((c) => c.fn === 'LMSSetValue' && c.args[0] === element).at(-1)?.args[1];
test('a learner who passes the quiz is recorded as passed', async ({ page }) => {
const { course, problems } = await openCourse(page);
// Play the course as a learner would. Replace these steps with your course's own.
await course.getByRole('button', { name: 'LMSInitialize', exact: true }).click();
await course.getByRole('button', { name: 'min 0 / max 100' }).click();
await course.getByRole('button', { name: '80', exact: true }).click();
await course.getByRole('button', { name: 'passed', exact: true }).click();
await course.getByRole('button', { name: 'LMSCommit', exact: true }).click();
await course.getByRole('button', { name: 'LMSFinish', exact: true }).click();
const calls = await callsAfterClosing(page);
expect(problems).toEqual([]);
expect(calls.filter((c) => c.fn === 'LMSInitialize')).toHaveLength(1);
expect(calls[0]?.fn).toBe('LMSInitialize');
expect(calls.at(-1)?.fn).toBe('LMSFinish');
expect(calls.filter((c) => c.result !== 'true')).toEqual([]);
expect(lastSet(calls, 'cmi.core.lesson_status')).toBe('passed');
expect(Number(lastSet(calls, 'cmi.core.score.raw'))).toBeGreaterThanOrEqual(80);
});The steps in the example click the buttons of Alder's own SCORM 1.2 test course, which it was checked with. Replace them with what a learner does in yours: click through the pages, answer the quiz. Playwright's getByRole finds buttons and links by their text, as a learner sees them.
What it checks
- The course started once, and its first call was the start.
- Its last call was the finish. The test closes the course first, the way a learner closes the tab, so a course that finishes when its window closes passes.
- No call came back refused, so nothing the course sent was lost.
- The status and score are what this learner should get.
- No file was missing from the folder, nothing was loaded from another site, and no JavaScript error was thrown.
Add a test for each path that matters: a learner who fails the quiz, one who leaves halfway (status “incomplete”, exit “suspend”), one who comes back.
For a SCORM 2004 course
Change these, and check the 2004 names in the last lines of the test:
Runtime: scorm2004.min.js instead of scorm12.min.js
API: new w.Scorm2004API({}) and w.API_1484_11 = api
Calls: Initialize, SetValue, Commit, Terminate, GetLastError
Checks: cmi.success_status, cmi.completion_status, cmi.score.scaled (0 to 1)Let an AI coding agent run it
Claude Code, Codex and Cursor can run npx playwright test themselves. Tell them to run it after every change to the course, and to fix the course, never the test, when it fails. Without that last rule, an agent may “fix” a failing test by changing what it expects. The rules to give an agent are in SCORM rules for Claude Code, Codex and Cursor.
What the test can't tell you, and the free check that can
The test proves the course talks SCORM correctly to one runtime. It can't show how a given LMS treats the result: some judge the score against a mastery score in the manifest, some turn “completed” into passed or failed, some open courses in a new window. So before you upload:
Drop the zip into Alder
Open Alder and drop the zip you will upload, not the folder you tested. It checks the zip layout and the manifest, including a manifest whose SCORM version doesn't match the code.
Play it once in the test player
Click “Launch the course” and take the path your test takes. The technical log lists the same calls in plain English, which is handy when a test fails and you want to see why.

The calls the example test checks, as Alder's log shows them for its SCORM 1.2 probe. Press “I’m done” and pick your LMS
Alder shows what an LMS would record, whether LMSs agree, and notes for your LMS under “Uploading to”. Then launch it once in your LMS's test area before learners see it.
How the test works
An LMS shows a course in a frame and puts the API on its own page around it. The test does the same on a made-up address, so the course finds the API the way it would in an LMS and runs over http, not from a file on disk.
Still not working?
- The test says the course never started or never finished: see AI-built course plays but records nothing.
- You want to test by hand, without code: see how to test a SCORM package without an LMS.