Skip to content

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:

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

  1. Unzip the course into a folder

    Put the package's files in course/ in your project, with imsmanifest.xml at its top. If you build the course yourself, point the test at the folder you zip.

  2. Install Playwright and scorm-again

    npm install --save-dev @playwright/test scorm-again, then npx 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.

  3. Save the test and run it

    Save the file below as tests/course.spec.ts, set LAUNCH to the href in the manifest, and run npx playwright test.

The test

tests/course.spec.ts
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:

  1. 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.

  2. 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.

    Alder's call log: LMSSetValue cmi.core.lesson_status passed returns true ('The course told the LMS the learner passed'), then LMSCommit, then LMSFinish ('The course ended its session').
    The calls the example test checks, as Alder's log shows them for its SCORM 1.2 probe.
  3. 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.

How the automated SCORM test worksA Playwright test opens a pretend LMS page and clicks through the course like a learner. The pretend LMS page holds a real SCORM runtime as window.API and records every call. The course runs in a frame on that page, served over http from the unzipped package, and finds the API as it would in an LMS. At the end the test checks the calls: started once, finished last, the status and score expected, and no value refused.THE TEST (PLAYWRIGHT)Opens the pretend LMS page andclicks through like a learner.THE PRETEND LMS PAGEA real SCORM runtime aswindow.API, recording each callthe course finds itTHE COURSE, IN A FRAMEServed from the unzipped folder,over http, not file://.its callsTHE TEST CHECKS THE CALLSStarted once, finished lastThe status and score expectedNo value refused by the LMSAny failure stops the build.
The pretend LMS page holds a real SCORM runtime; the test reads every call the course makes to it.

Still not working?