scorm-kit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * scorm-lint — static analyzer for SCORM 1.2 packages.
4
+ *
5
+ * scorm-lint path/to/package.zip
6
+ * scorm-lint path/to/unzipped-dir
7
+ *
8
+ * Catches the issues that most often slip past Storyline's own publish step
9
+ * and cause silent failures in production LMSs:
10
+ *
11
+ * manifest-missing imsmanifest.xml not at the root of the package
12
+ * manifest-malformed not parseable XML
13
+ * manifest-no-schema schema/schemaversion not 'ADL SCORM' / '1.2'
14
+ * manifest-no-resource no <resource> with scormtype="sco"
15
+ * manifest-href-missing resource href points to a file not in the package
16
+ * manifest-namespace adlcp namespace declared on child, not root
17
+ * manifest-no-masteryscore organization item has no <adlcp:masteryscore>
18
+ *
19
+ * api-no-wrapper launch HTML has no recognisable SCORM API wrapper / discovery
20
+ * api-init-missing no LMSInitialize / scorm.init call found
21
+ * api-finish-missing no LMSFinish / scorm.finish call found
22
+ * api-set-status no LMSSetValue("cmi.core.lesson_status", ...) call
23
+ *
24
+ * interactions-collision same cmi.interactions.N.id value used twice
25
+ *
26
+ * assets-large asset over a configurable size threshold (default 50MB)
27
+ * assets-broken-ref <img>, <video>, <audio>, <source>, <link>, <script>
28
+ * references a file not present in the package
29
+ * assets-mime-mp4 mp4 served without a script that sets the right mime
30
+ * type (informational only)
31
+ *
32
+ * Exit codes: 0 = clean, 1 = warnings only, 2 = errors.
33
+ */
34
+ "use strict";
35
+
36
+ var fs = require("fs");
37
+ var path = require("path");
38
+ var { spawnSync } = require("child_process");
39
+ var verifyConfinement = require("../confine");
40
+
41
+ var RULES = {
42
+ errors: new Set([
43
+ "manifest-missing", "manifest-malformed", "manifest-no-schema",
44
+ "manifest-no-resource", "manifest-href-missing",
45
+ "interactions-collision", "assets-broken-ref",
46
+ ]),
47
+ warnings: new Set([
48
+ "manifest-namespace", "manifest-no-masteryscore",
49
+ "api-no-wrapper", "api-init-missing", "api-finish-missing", "api-set-status",
50
+ "assets-large",
51
+ ]),
52
+ info: new Set([
53
+ "assets-mime-mp4",
54
+ ]),
55
+ };
56
+
57
+ var LARGE_ASSET_BYTES = 50 * 1024 * 1024;
58
+
59
+ function severity(rule) {
60
+ if (RULES.errors.has(rule)) return "error";
61
+ if (RULES.warnings.has(rule)) return "warn";
62
+ return "info";
63
+ }
64
+
65
+ function color(s, c) {
66
+ if (!process.stdout.isTTY) return s;
67
+ var codes = { red: 31, yellow: 33, blue: 34, gray: 90, bold: 1 };
68
+ return "\x1b[" + codes[c] + "m" + s + "\x1b[0m";
69
+ }
70
+
71
+ function usage() {
72
+ console.error("Usage: scorm-lint <package.zip | unzipped-dir> [--no-color] [--json]");
73
+ process.exit(2);
74
+ }
75
+
76
+ // ---------- I/O ----------
77
+
78
+ function isZip(p) { return p.toLowerCase().endsWith(".zip"); }
79
+
80
+ function unzipToTemp(zipPath) {
81
+ var tmp = fs.mkdtempSync(path.join(require("os").tmpdir(), "scorm-lint-"));
82
+ var res = spawnSync("unzip", ["-q", "-o", zipPath, "-d", tmp]);
83
+ if (res.status !== 0) throw new Error("Failed to unzip: " + (res.stderr && res.stderr.toString()));
84
+ verifyConfinement(tmp);
85
+ return tmp;
86
+ }
87
+
88
+ function walk(dir, baseLen) {
89
+ baseLen = baseLen != null ? baseLen : dir.length + 1;
90
+ var out = [];
91
+ fs.readdirSync(dir).forEach(function (entry) {
92
+ var full = path.join(dir, entry);
93
+ var st = fs.statSync(full);
94
+ if (st.isDirectory()) out = out.concat(walk(full, baseLen));
95
+ else out.push({ rel: full.slice(baseLen).replace(/\\/g, "/"), full: full, size: st.size });
96
+ });
97
+ return out;
98
+ }
99
+
100
+ // ---------- Tiny XML scan (regex-based — robust enough for manifest checks) ----------
101
+
102
+ function readText(p) { try { return fs.readFileSync(p, "utf8"); } catch (e) { return null; } }
103
+
104
+ function checkManifest(rootDir, files, findings) {
105
+ var manifestPath = path.join(rootDir, "imsmanifest.xml");
106
+ if (!fs.existsSync(manifestPath)) {
107
+ findings.push(["manifest-missing", "imsmanifest.xml not found at package root", null]);
108
+ return null;
109
+ }
110
+ var xml = readText(manifestPath);
111
+ if (!xml || !/<manifest\b/.test(xml)) {
112
+ findings.push(["manifest-malformed", "imsmanifest.xml does not appear to be a valid SCORM manifest", "imsmanifest.xml"]);
113
+ return null;
114
+ }
115
+
116
+ // schema
117
+ var schema = (xml.match(/<schema\s*>([^<]+)<\/schema>/) || ["", ""])[1].trim();
118
+ var schemaVersion = (xml.match(/<schemaversion\s*>([^<]+)<\/schemaversion>/) || ["", ""])[1].trim();
119
+ if (schema !== "ADL SCORM" || !/^1\.2/.test(schemaVersion)) {
120
+ findings.push(["manifest-no-schema",
121
+ "schema=" + JSON.stringify(schema) + " schemaversion=" + JSON.stringify(schemaVersion) +
122
+ " — expected ADL SCORM / 1.2",
123
+ "imsmanifest.xml"]);
124
+ }
125
+
126
+ // adlcp namespace declared on root?
127
+ var rootTag = xml.match(/<manifest\b[^>]*>/);
128
+ if (rootTag && !/xmlns:adlcp\s*=/.test(rootTag[0])) {
129
+ findings.push(["manifest-namespace",
130
+ "adlcp namespace not declared on <manifest> root element — some strict LMS implementations reject this",
131
+ "imsmanifest.xml"]);
132
+ }
133
+
134
+ // resources
135
+ var resourceMatches = Array.from(xml.matchAll(/<resource\b[^>]*?(?:\/>|>[\s\S]*?<\/resource>)/g));
136
+ if (resourceMatches.length === 0) {
137
+ findings.push(["manifest-no-resource", "no <resource> elements found", "imsmanifest.xml"]);
138
+ }
139
+ var primaryHref = null;
140
+ resourceMatches.forEach(function (m) {
141
+ var blob = m[0];
142
+ var typ = (blob.match(/adlcp:scormtype\s*=\s*"([^"]+)"/) || ["", ""])[1];
143
+ var href = (blob.match(/href\s*=\s*"([^"]+)"/) || ["", ""])[1];
144
+ if (typ === "sco" && href && !primaryHref) primaryHref = href;
145
+ if (href) {
146
+ if (!files.find(function (f) { return f.rel === href; })) {
147
+ findings.push(["manifest-href-missing", "manifest references missing file: " + href, "imsmanifest.xml"]);
148
+ }
149
+ }
150
+ });
151
+
152
+ // masteryscore
153
+ if (!/<adlcp:masteryscore\s*>/.test(xml)) {
154
+ findings.push(["manifest-no-masteryscore",
155
+ "no <adlcp:masteryscore> found — completion will rely solely on cmi.core.lesson_status setting",
156
+ "imsmanifest.xml"]);
157
+ }
158
+
159
+ return primaryHref;
160
+ }
161
+
162
+ // ---------- HTML / JS scans ----------
163
+
164
+ function scanLaunchHtml(rootDir, launchHref, files, findings) {
165
+ var p = path.join(rootDir, launchHref);
166
+ var html = readText(p);
167
+ if (!html) return;
168
+
169
+ // Walk the script srcs from this HTML — read each .js too for API discovery.
170
+ // Also concatenate inline <script>...</script> bodies — Storyline sometimes
171
+ // inlines its API plumbing, and our pattern matches need to see that too.
172
+ var scriptSrcs = Array.from(html.matchAll(/<script\b[^>]*\bsrc\s*=\s*"([^"]+)"/g)).map(function (m) { return m[1]; });
173
+ var inlineScripts = Array.from(html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/g))
174
+ .map(function (m) { return m[1]; });
175
+ var jsBlob = html + "\n" +
176
+ inlineScripts.join("\n") + "\n" +
177
+ scriptSrcs
178
+ .map(function (src) {
179
+ var jsPath = path.join(path.dirname(p), src);
180
+ return readText(jsPath) || "";
181
+ })
182
+ .join("\n");
183
+
184
+ // API wrapper / discovery heuristics
185
+ var hasWrapper =
186
+ /window\.API\b/.test(jsBlob) ||
187
+ /findAPI\s*\(/.test(jsBlob) ||
188
+ /pipwerks\.SCORM/.test(jsBlob) ||
189
+ /LMSInitialize\s*\(/.test(jsBlob);
190
+ if (!hasWrapper) {
191
+ findings.push(["api-no-wrapper",
192
+ "launch HTML has no recognisable SCORM API wrapper — course will not talk to the LMS",
193
+ launchHref]);
194
+ }
195
+
196
+ if (!/LMSInitialize\s*\(/.test(jsBlob) && !/scorm\.init\s*\(/.test(jsBlob)) {
197
+ findings.push(["api-init-missing", "no LMSInitialize / scorm.init call found", launchHref]);
198
+ }
199
+ if (!/LMSFinish\s*\(/.test(jsBlob) && !/scorm\.finish\s*\(/.test(jsBlob)) {
200
+ findings.push(["api-finish-missing", "no LMSFinish / scorm.finish call found", launchHref]);
201
+ }
202
+ if (!/cmi\.core\.lesson_status/.test(jsBlob)) {
203
+ findings.push(["api-set-status", "no cmi.core.lesson_status reference — completion may never be reported", launchHref]);
204
+ }
205
+
206
+ // Interaction-id collision check
207
+ var ids = {};
208
+ Array.from(jsBlob.matchAll(/cmi\.interactions\.(\d+)\.id["']?\s*[,)]\s*["']([^"']+)["']/g)).forEach(function (m) {
209
+ var idx = m[1], val = m[2];
210
+ if (ids[val]) findings.push(["interactions-collision",
211
+ "interaction id " + JSON.stringify(val) + " used at indices " + ids[val] + " and " + idx, launchHref]);
212
+ else ids[val] = idx;
213
+ });
214
+
215
+ // Broken asset refs (img/video/audio src, source src, link href, script src)
216
+ var assetRefs = [];
217
+ Array.from(html.matchAll(/<(?:img|video|audio|source|script)\b[^>]*\bsrc\s*=\s*"([^"]+)"/g))
218
+ .forEach(function (m) { assetRefs.push(m[1]); });
219
+ Array.from(html.matchAll(/<link\b[^>]*\bhref\s*=\s*"([^"]+)"/g))
220
+ .forEach(function (m) { assetRefs.push(m[1]); });
221
+
222
+ assetRefs.forEach(function (ref) {
223
+ if (/^(https?:)?\/\//i.test(ref) || /^data:/i.test(ref) || /^javascript:/i.test(ref)) return;
224
+ var resolved = path.posix.normalize(path.posix.join(path.dirname(launchHref), ref));
225
+ if (!files.find(function (f) { return f.rel === resolved; })) {
226
+ findings.push(["assets-broken-ref", "asset referenced but not in package: " + ref, launchHref]);
227
+ }
228
+ });
229
+ }
230
+
231
+ function checkAssets(files, findings) {
232
+ files.forEach(function (f) {
233
+ if (f.size > LARGE_ASSET_BYTES) {
234
+ findings.push(["assets-large", "large asset (" + (f.size / 1024 / 1024).toFixed(1) + "MB): " + f.rel, f.rel]);
235
+ }
236
+ if (/\.mp4$/i.test(f.rel)) {
237
+ findings.push(["assets-mime-mp4", "mp4 present (" + f.rel + ") — ensure LMS serves video/mp4 MIME type", f.rel]);
238
+ }
239
+ });
240
+ }
241
+
242
+ // ---------- output ----------
243
+
244
+ function report(findings, opts) {
245
+ var counts = { error: 0, warn: 0, info: 0 };
246
+ findings.forEach(function (f) { counts[severity(f[0])]++; });
247
+
248
+ if (opts.json) {
249
+ var rows = findings.map(function (f) {
250
+ return { rule: f[0], severity: severity(f[0]), message: f[1], file: f[2] };
251
+ });
252
+ console.log(JSON.stringify({ counts: counts, findings: rows }, null, 2));
253
+ return;
254
+ }
255
+
256
+ if (findings.length === 0) {
257
+ console.log(color("✓ scorm-lint: 0 issues — package looks clean.", "blue"));
258
+ return;
259
+ }
260
+
261
+ findings.forEach(function (f) {
262
+ var rule = f[0], msg = f[1], file = f[2];
263
+ var sev = severity(rule);
264
+ var tag = sev === "error" ? color("ERROR", "red")
265
+ : sev === "warn" ? color("WARN ", "yellow")
266
+ : color("INFO ", "gray");
267
+ var loc = file ? color(" [" + file + "]", "gray") : "";
268
+ console.log(tag + " " + color(rule.padEnd(24), "bold") + " " + msg + loc);
269
+ });
270
+ console.log();
271
+ console.log("Summary: " +
272
+ counts.error + " error" + (counts.error === 1 ? "" : "s") + ", " +
273
+ counts.warn + " warning" + (counts.warn === 1 ? "" : "s") + ", " +
274
+ counts.info + " info");
275
+ }
276
+
277
+ // ---------- main ----------
278
+
279
+ function main() {
280
+ var args = process.argv.slice(2);
281
+ if (!args.length || args.indexOf("--help") >= 0 || args.indexOf("-h") >= 0) usage();
282
+
283
+ var inputPath = null;
284
+ var opts = { color: process.stdout.isTTY, json: false };
285
+ args.forEach(function (a) {
286
+ if (a === "--no-color") opts.color = false;
287
+ else if (a === "--json") opts.json = true;
288
+ else if (!inputPath) inputPath = a;
289
+ else usage();
290
+ });
291
+ if (!inputPath) usage();
292
+
293
+ var rootDir = inputPath;
294
+ var tempDir = null;
295
+ if (isZip(inputPath)) {
296
+ tempDir = unzipToTemp(inputPath);
297
+ rootDir = tempDir;
298
+ } else if (!fs.statSync(inputPath).isDirectory()) {
299
+ console.error("Input must be a .zip or a directory");
300
+ process.exit(2);
301
+ }
302
+
303
+ var files = walk(rootDir);
304
+ var findings = [];
305
+
306
+ var launchHref = checkManifest(rootDir, files, findings);
307
+ if (launchHref) scanLaunchHtml(rootDir, launchHref, files, findings);
308
+ checkAssets(files, findings);
309
+
310
+ report(findings, opts);
311
+
312
+ if (tempDir) try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (e) {}
313
+
314
+ var counts = { error: 0, warn: 0 };
315
+ findings.forEach(function (f) {
316
+ var s = severity(f[0]);
317
+ if (s === "error") counts.error++;
318
+ else if (s === "warn") counts.warn++;
319
+ });
320
+ process.exit(counts.error > 0 ? 2 : counts.warn > 0 ? 1 : 0);
321
+ }
322
+
323
+ main();
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * scorm-mock-lms — local SCORM 1.2 runtime for testing courses without an LMS.
4
+ *
5
+ * scorm-mock-lms <package.zip | dir> [--port 8080] [--persist session.json]
6
+ * [--cmi key=value]... [--fail set|init|none]
7
+ *
8
+ * Starts a tiny HTTP server that:
9
+ * - serves the unpacked SCORM package at /pkg/...
10
+ * - serves the mock LMS shell at /
11
+ *
12
+ * The shell loads the package's launch HTML in an iframe. The shell window
13
+ * exposes window.API — a full SCORM 1.2 RTE — and records every method call
14
+ * with timestamp, args, return value, and last-error code. The course (in
15
+ * the iframe) walks window.parent looking for `API` and finds the mock.
16
+ *
17
+ * Use it to:
18
+ * - debug a SCORM package without uploading to Moodle
19
+ * - inject failures (LMSSetValue returns "false") to test error handling
20
+ * - export a full session log as JSON for regression tests
21
+ * - preset CMI values (student_id, completion_status) for resume testing
22
+ */
23
+ "use strict";
24
+
25
+ var fs = require("fs");
26
+ var path = require("path");
27
+ var os = require("os");
28
+ var http = require("http");
29
+ var url = require("url");
30
+ var { spawnSync } = require("child_process");
31
+ var verifyConfinement = require("../confine");
32
+
33
+ // ---------- args ----------------------------------------------------------
34
+
35
+ function parseArgs(argv) {
36
+ var a = { input: "", port: 8080, persist: "", cmi: {}, fail: "none", open: false };
37
+ for (var i = 0; i < argv.length; i++) {
38
+ var k = argv[i];
39
+ if (k === "--port") a.port = +argv[++i];
40
+ else if (k === "--persist") a.persist = argv[++i];
41
+ else if (k === "--cmi") {
42
+ var kv = argv[++i] || "";
43
+ var eq = kv.indexOf("=");
44
+ if (eq < 0) { console.error("--cmi expects key=value"); process.exit(2); }
45
+ a.cmi[kv.slice(0, eq)] = kv.slice(eq + 1);
46
+ }
47
+ else if (k === "--fail") a.fail = argv[++i];
48
+ else if (k === "--open") a.open = true;
49
+ else if (k === "-h" || k === "--help") { usage(); process.exit(0); }
50
+ else if (k[0] === "-") { console.error("Unknown flag: " + k); process.exit(2); }
51
+ else if (!a.input) a.input = k;
52
+ else { console.error("Unexpected arg: " + k); process.exit(2); }
53
+ }
54
+ if (!a.input) { usage(); process.exit(2); }
55
+ if (["none", "set", "init", "finish", "commit"].indexOf(a.fail) < 0) {
56
+ console.error("--fail must be one of: none, set, init, finish, commit");
57
+ process.exit(2);
58
+ }
59
+ return a;
60
+ }
61
+ function usage() {
62
+ console.error("Usage: scorm-mock-lms <package.zip | dir> [options]");
63
+ console.error(" --port N listen port (default 8080)");
64
+ console.error(" --persist file.json save session log on Ctrl-C");
65
+ console.error(" --cmi key=value preset a CMI value (repeatable)");
66
+ console.error(" --fail none|set|init|finish|commit inject API failure");
67
+ console.error(" --open open default browser on start");
68
+ }
69
+
70
+ // ---------- package prep --------------------------------------------------
71
+
72
+ function unzipToTemp(zipPath) {
73
+ var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "scorm-mock-"));
74
+ var r = spawnSync("unzip", ["-q", "-o", zipPath, "-d", tmp]);
75
+ if (r.status !== 0) throw new Error("unzip: " + r.stderr.toString());
76
+ verifyConfinement(tmp);
77
+ return tmp;
78
+ }
79
+
80
+ function findLaunchHref(root) {
81
+ var mPath = path.join(root, "imsmanifest.xml");
82
+ if (!fs.existsSync(mPath)) return null;
83
+ var xml = fs.readFileSync(mPath, "utf8");
84
+ var m = /<resource\b[^>]*\bscormtype\s*=\s*["']sco["'][^>]*\bhref\s*=\s*["']([^"']+)["']/i.exec(xml);
85
+ if (!m) m = /<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*\bscormtype\s*=\s*["']sco["']/i.exec(xml);
86
+ if (!m) m = /<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["']/i.exec(xml);
87
+ return m ? m[1] : null;
88
+ }
89
+
90
+ // ---------- HTTP server ---------------------------------------------------
91
+
92
+ var MIME = {
93
+ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
94
+ ".js": "application/javascript; charset=utf-8",
95
+ ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8",
96
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
97
+ ".svg": "image/svg+xml", ".webp": "image/webp",
98
+ ".mp4": "video/mp4", ".webm": "video/webm",
99
+ ".mp3": "audio/mpeg", ".wav": "audio/wav",
100
+ ".vtt": "text/vtt", ".xml": "application/xml; charset=utf-8",
101
+ ".woff": "font/woff", ".woff2": "font/woff2",
102
+ };
103
+
104
+ function serveFile(filePath, res) {
105
+ fs.stat(filePath, function (err, st) {
106
+ if (err || !st.isFile()) { res.writeHead(404); return res.end("not found"); }
107
+ var mime = MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream";
108
+ res.writeHead(200, { "content-type": mime, "cache-control": "no-cache" });
109
+ fs.createReadStream(filePath).pipe(res);
110
+ });
111
+ }
112
+
113
+ function safeJoin(base, rel) {
114
+ var resolved = path.resolve(base, "." + (rel.startsWith("/") ? rel : "/" + rel));
115
+ if (resolved !== base && resolved.indexOf(base + path.sep) !== 0) return null;
116
+ return resolved;
117
+ }
118
+
119
+ function startServer(args, packageRoot, launchHref, webDir) {
120
+ var server = http.createServer(function (req, res) {
121
+ var u = url.parse(req.url);
122
+ var p = decodeURIComponent(u.pathname || "/");
123
+
124
+ if (p === "/") return serveFile(path.join(webDir, "mock-lms.html"), res);
125
+ if (p === "/config.json") {
126
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
127
+ return res.end(JSON.stringify({
128
+ // Encode each path segment so a launch href like "story space.html"
129
+ // round-trips correctly through fetch + iframe src.
130
+ launchUrl: "/pkg/" + launchHref.split("/").map(encodeURIComponent).join("/"),
131
+ cmiPresets: args.cmi,
132
+ fail: args.fail,
133
+ }));
134
+ }
135
+ if (p.startsWith("/assets/")) {
136
+ var asset = safeJoin(webDir, p.slice("/assets".length));
137
+ if (!asset) { res.writeHead(403); return res.end("forbidden"); }
138
+ return serveFile(asset, res);
139
+ }
140
+ if (p.startsWith("/pkg/")) {
141
+ var rel = p.slice("/pkg".length);
142
+ var fp = safeJoin(packageRoot, rel);
143
+ if (!fp) { res.writeHead(403); return res.end("forbidden"); }
144
+ return serveFile(fp, res);
145
+ }
146
+ res.writeHead(404); res.end("not found");
147
+ });
148
+ server.listen(args.port, function () {
149
+ console.log("scorm-mock-lms ready → http://localhost:" + args.port);
150
+ console.log(" package: " + packageRoot);
151
+ console.log(" launch: " + launchHref);
152
+ if (Object.keys(args.cmi).length) console.log(" presets: " + JSON.stringify(args.cmi));
153
+ if (args.fail !== "none") console.log(" fail: " + args.fail);
154
+ if (args.open) tryOpen("http://localhost:" + args.port);
155
+ console.log("\nCtrl-C to stop.");
156
+ });
157
+ return server;
158
+ }
159
+
160
+ function tryOpen(target) {
161
+ var cmd = process.platform === "darwin" ? "open" :
162
+ process.platform === "win32" ? "start" : "xdg-open";
163
+ try { spawnSync(cmd, [target], { stdio: "ignore", detached: true }); } catch (e) {}
164
+ }
165
+
166
+ // ---------- main ----------------------------------------------------------
167
+
168
+ function main() {
169
+ var args = parseArgs(process.argv.slice(2));
170
+ if (!fs.existsSync(args.input)) { console.error("Not found: " + args.input); process.exit(2); }
171
+
172
+ var packageRoot, cleanup = function () {};
173
+ var st = fs.statSync(args.input);
174
+ if (st.isFile()) {
175
+ packageRoot = unzipToTemp(args.input);
176
+ cleanup = function () { try { fs.rmSync(packageRoot, { recursive: true, force: true }); } catch (e) {} };
177
+ } else {
178
+ packageRoot = path.resolve(args.input);
179
+ }
180
+
181
+ var launchHref = findLaunchHref(packageRoot);
182
+ if (!launchHref) {
183
+ console.error("No launch resource found in imsmanifest.xml.");
184
+ cleanup(); process.exit(2);
185
+ }
186
+
187
+ var webDir = path.join(__dirname, "web");
188
+ var server = startServer(args, packageRoot, launchHref, webDir);
189
+
190
+ process.on("SIGINT", function () {
191
+ console.log("\nstopping...");
192
+ server.close(function () { cleanup(); process.exit(0); });
193
+ setTimeout(function () { process.exit(0); }, 1000).unref();
194
+ });
195
+ }
196
+
197
+ main();
@@ -0,0 +1,57 @@
1
+ * { box-sizing: border-box; }
2
+ html, body { height: 100%; margin: 0; font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #1a1a1a; }
3
+ body { display: flex; flex-direction: column; background: #f4f5f7; }
4
+ header {
5
+ display: flex; align-items: center; gap: 0.75rem;
6
+ padding: 0.5rem 1rem; background: #1f2937; color: #fff;
7
+ border-bottom: 1px solid #111827;
8
+ }
9
+ header strong { font-size: 14px; }
10
+ header .state {
11
+ font-size: 11px; padding: 2px 8px; border-radius: 10px;
12
+ background: #6b7280; color: #fff;
13
+ }
14
+ header .state.connected { background: #10b981; }
15
+ header .state.terminated { background: #ef4444; }
16
+ header .spacer { flex: 1; }
17
+ header label.fail { font-size: 12px; display: flex; gap: 4px; align-items: center; }
18
+ header select, header button {
19
+ font: inherit; padding: 4px 10px; border: 0; border-radius: 4px;
20
+ background: #374151; color: #fff; cursor: pointer;
21
+ }
22
+ header button:hover, header select:hover { background: #4b5563; }
23
+ main { display: flex; flex: 1; min-height: 0; }
24
+ .course { flex: 1; min-width: 0; background: #fff; }
25
+ .course iframe { width: 100%; height: 100%; border: 0; display: block; }
26
+ .panel { width: 460px; min-width: 320px; background: #fff; border-left: 1px solid #e5e7eb; display: flex; flex-direction: column; }
27
+ .tabs { display: flex; border-bottom: 1px solid #e5e7eb; }
28
+ .tabs button {
29
+ flex: 1; padding: 0.5rem; background: #fff; border: 0; cursor: pointer;
30
+ border-bottom: 2px solid transparent; font: inherit; color: #6b7280;
31
+ }
32
+ .tabs button.active { color: #1f2937; border-bottom-color: #2563eb; font-weight: 600; }
33
+ .tab-body { flex: 1; min-height: 0; }
34
+ .tab-pane { display: none; height: 100%; overflow: auto; }
35
+ .tab-pane.active { display: block; }
36
+ .log-toolbar {
37
+ padding: 0.5rem; display: flex; gap: 0.5rem; align-items: center;
38
+ border-bottom: 1px solid #e5e7eb; background: #f9fafb;
39
+ position: sticky; top: 0; z-index: 1;
40
+ }
41
+ .log-toolbar input { flex: 1; padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 4px; font: inherit; }
42
+ .count { color: #6b7280; font-size: 11px; }
43
+ .log { list-style: none; margin: 0; padding: 0; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
44
+ .log li {
45
+ display: grid; grid-template-columns: 60px 110px 1fr 60px;
46
+ gap: 0.5rem; padding: 4px 0.5rem; border-bottom: 1px solid #f3f4f6;
47
+ align-items: baseline;
48
+ }
49
+ .log li:hover { background: #f9fafb; }
50
+ .log .t { color: #9ca3af; font-size: 11px; }
51
+ .log .m { color: #1d4ed8; font-weight: 600; }
52
+ .log .a { color: #374151; word-break: break-all; }
53
+ .log .r { color: #059669; text-align: right; font-size: 11px; }
54
+ .log li.err .m { color: #b91c1c; }
55
+ .log li.err .r { color: #b91c1c; }
56
+ .log li.hidden { display: none; }
57
+ #cmi { padding: 0.75rem; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; margin: 0; white-space: pre-wrap; }
@@ -0,0 +1,53 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>scorm-mock-lms</title>
6
+ <link rel="stylesheet" href="/assets/mock-lms.css">
7
+ <script src="/assets/mock-lms.js"></script>
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <strong>scorm-mock-lms</strong>
12
+ <span class="state" id="state">disconnected</span>
13
+ <span class="spacer"></span>
14
+ <label class="fail">
15
+ Fault inject:
16
+ <select id="fail-mode">
17
+ <option value="none">none</option>
18
+ <option value="set">LMSSetValue → "false"</option>
19
+ <option value="init">LMSInitialize → "false"</option>
20
+ <option value="commit">LMSCommit → "false"</option>
21
+ <option value="finish">LMSFinish → "false"</option>
22
+ </select>
23
+ </label>
24
+ <button id="restart">Restart</button>
25
+ <button id="clear">Clear log</button>
26
+ <button id="export">Export JSON</button>
27
+ </header>
28
+
29
+ <main>
30
+ <section class="course">
31
+ <iframe id="course" title="SCORM course"></iframe>
32
+ </section>
33
+ <aside class="panel">
34
+ <nav class="tabs">
35
+ <button data-tab="log" class="active">Call log</button>
36
+ <button data-tab="cmi">CMI state</button>
37
+ </nav>
38
+ <div class="tab-body">
39
+ <div id="tab-log" class="tab-pane active">
40
+ <div class="log-toolbar">
41
+ <input type="search" id="filter" placeholder="filter by method or key…">
42
+ <span id="log-count" class="count">0 calls</span>
43
+ </div>
44
+ <ol id="log" class="log"></ol>
45
+ </div>
46
+ <div id="tab-cmi" class="tab-pane">
47
+ <pre id="cmi"></pre>
48
+ </div>
49
+ </div>
50
+ </aside>
51
+ </main>
52
+ </body>
53
+ </html>