scorm-qa 1.0.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.
- package/package.json +32 -0
- package/src/install-browsers.js +16 -0
- package/src/qa.js +410 -0
- package/src/report.js +175 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scorm-qa",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Automated QA tool for SCORM packages — runs 7 checks and generates a bug report",
|
|
5
|
+
"main": "src/qa.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"scorm-qa": "src/qa.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"postinstall": "node src/install-browsers.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"scorm",
|
|
14
|
+
"elearning",
|
|
15
|
+
"qa",
|
|
16
|
+
"testing",
|
|
17
|
+
"playwright",
|
|
18
|
+
"cli"
|
|
19
|
+
],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"jszip": "^3.10.1",
|
|
24
|
+
"playwright": "1.44.1"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install-browsers.js
|
|
3
|
+
* Automatically installs Playwright's Chromium browser after npm install.
|
|
4
|
+
* Runs as a postinstall hook.
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
console.log('\n[scorm-qa] Installing Playwright Chromium browser...');
|
|
11
|
+
try {
|
|
12
|
+
execSync('npx playwright install chromium', { stdio: 'inherit' });
|
|
13
|
+
console.log('[scorm-qa] Chromium installed successfully.\n');
|
|
14
|
+
} catch (e) {
|
|
15
|
+
console.warn('[scorm-qa] Could not auto-install Chromium. Run manually: npx playwright install chromium\n');
|
|
16
|
+
}
|
package/src/qa.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* qa.js — SCORM QA CLI Tool
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* scorm-qa <path-to-scorm-folder-or-zip>
|
|
7
|
+
* scorm-qa ./my-course/
|
|
8
|
+
* scorm-qa ./my-course.zip
|
|
9
|
+
*
|
|
10
|
+
* Output:
|
|
11
|
+
* QA-Report-<modulename>-<date>.html saved next to the input
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { chromium } = require('playwright');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const http = require('http');
|
|
20
|
+
const { execSync } = require('child_process');
|
|
21
|
+
const { buildReport } = require('./report');
|
|
22
|
+
|
|
23
|
+
// ─── Help ─────────────────────────────────────────────────────────────────────
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
26
|
+
console.log(`
|
|
27
|
+
scorm-qa — Automated QA for SCORM packages
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
scorm-qa <input> Run QA on a SCORM folder or zip file
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
scorm-qa ./my-course/
|
|
34
|
+
scorm-qa ./my-course.zip
|
|
35
|
+
|
|
36
|
+
Output:
|
|
37
|
+
QA-Report-<name>-<date>.html saved next to the input file/folder
|
|
38
|
+
|
|
39
|
+
Checks performed:
|
|
40
|
+
1. Module loads without fatal errors
|
|
41
|
+
2. Page title is set
|
|
42
|
+
3. SCORM API connection (LMSInitialize called)
|
|
43
|
+
4. JavaScript errors
|
|
44
|
+
5. Broken images
|
|
45
|
+
6. Navigation / page progression
|
|
46
|
+
7. SCORM completion status
|
|
47
|
+
`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const input = args[0];
|
|
52
|
+
|
|
53
|
+
// ─── Resolve input ────────────────────────────────────────────────────────────
|
|
54
|
+
const absInput = path.resolve(input.replace(/[\\/]+$/, ''));
|
|
55
|
+
if (!fs.existsSync(absInput)) {
|
|
56
|
+
console.error('Error: Path not found:', absInput);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Resolve serve directory (unzip if needed) ────────────────────────────────
|
|
61
|
+
let serveDir = absInput;
|
|
62
|
+
let tmpDir = null;
|
|
63
|
+
|
|
64
|
+
// Zip extraction is handled inside main() asynchronously
|
|
65
|
+
|
|
66
|
+
// ─── Start local HTTP server ──────────────────────────────────────────────────
|
|
67
|
+
const PORT = 18765;
|
|
68
|
+
const MIME = {
|
|
69
|
+
'.html': 'text/html', '.js': 'application/javascript',
|
|
70
|
+
'.css': 'text/css', '.png': 'image/png',
|
|
71
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
72
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml',
|
|
73
|
+
'.mp3': 'audio/mpeg', '.mp4': 'video/mp4',
|
|
74
|
+
'.m4a': 'audio/m4a', '.wav': 'audio/wav',
|
|
75
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2',
|
|
76
|
+
'.json': 'application/json', '.xml': 'application/xml',
|
|
77
|
+
'.webm': 'video/webm',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function serveFile(req, res) {
|
|
81
|
+
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
82
|
+
const filePath = path.join(serveDir, urlPath === '/' ? 'index.html' : urlPath);
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
|
85
|
+
const idx = path.join(filePath, 'index.html');
|
|
86
|
+
if (fs.existsSync(idx)) {
|
|
87
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
88
|
+
res.end(fs.readFileSync(idx));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
res.writeHead(404); res.end('Not found'); return;
|
|
92
|
+
}
|
|
93
|
+
const mime = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
94
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
95
|
+
res.end(fs.readFileSync(filePath));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Screenshot helper ────────────────────────────────────────────────────────
|
|
99
|
+
const SS_DIR = path.join(path.dirname(absInput), '_qa_screenshots');
|
|
100
|
+
fs.mkdirSync(SS_DIR, { recursive: true });
|
|
101
|
+
|
|
102
|
+
async function screenshot(page, name) {
|
|
103
|
+
const file = path.join(SS_DIR, name + '.png');
|
|
104
|
+
await page.screenshot({ path: file, fullPage: false });
|
|
105
|
+
return file;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
109
|
+
async function main() {
|
|
110
|
+
// Handle zip extraction async
|
|
111
|
+
if (absInput.toLowerCase().endsWith('.zip')) {
|
|
112
|
+
const JSZip = require('jszip');
|
|
113
|
+
const zipBuf = fs.readFileSync(absInput);
|
|
114
|
+
const zip = await JSZip.loadAsync(zipBuf);
|
|
115
|
+
for (const [relPath, file] of Object.entries(zip.files)) {
|
|
116
|
+
if (file.dir) continue;
|
|
117
|
+
const outPath = path.join(tmpDir, relPath);
|
|
118
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
119
|
+
fs.writeFileSync(outPath, await file.async('nodebuffer'));
|
|
120
|
+
}
|
|
121
|
+
// Re-find index.html folder
|
|
122
|
+
function findIndexDir(dir) {
|
|
123
|
+
for (const f of fs.readdirSync(dir)) {
|
|
124
|
+
const full = path.join(dir, f);
|
|
125
|
+
if (f.toLowerCase() === 'index.html') return dir;
|
|
126
|
+
if (fs.statSync(full).isDirectory()) {
|
|
127
|
+
const found = findIndexDir(full);
|
|
128
|
+
if (found) return found;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
serveDir = findIndexDir(tmpDir) || tmpDir;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const server = http.createServer(serveFile);
|
|
137
|
+
server.listen(PORT);
|
|
138
|
+
const BASE_URL = `http://localhost:${PORT}`;
|
|
139
|
+
|
|
140
|
+
console.log('\n🔍 SCORM QA Tool');
|
|
141
|
+
console.log(' Input: ', absInput);
|
|
142
|
+
console.log(' Serving: ', serveDir);
|
|
143
|
+
console.log(' URL: ', BASE_URL);
|
|
144
|
+
console.log(' Starting browser...\n');
|
|
145
|
+
|
|
146
|
+
const browser = await chromium.launch({ headless: true });
|
|
147
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
148
|
+
|
|
149
|
+
// Inject SCORM 1.2 API stub + error collector
|
|
150
|
+
await page.addInitScript(() => {
|
|
151
|
+
const store = {
|
|
152
|
+
'cmi.core.student_name': 'QA Tester',
|
|
153
|
+
'cmi.core.student_id': 'qa-001',
|
|
154
|
+
'cmi.core.lesson_status':'incomplete',
|
|
155
|
+
'cmi.core.entry': 'ab-initio',
|
|
156
|
+
'cmi.core.credit': 'credit',
|
|
157
|
+
'cmi.core.lesson_mode': 'normal',
|
|
158
|
+
};
|
|
159
|
+
const calls = [];
|
|
160
|
+
window.__scorm__ = { store, calls, initialized: false };
|
|
161
|
+
window.API = {
|
|
162
|
+
LMSInitialize: () => { window.__scorm__.initialized = true; calls.push({ fn: 'Initialize' }); return 'true'; },
|
|
163
|
+
LMSFinish: () => { calls.push({ fn: 'Finish' }); return 'true'; },
|
|
164
|
+
LMSGetValue: (k) => { calls.push({ fn: 'GetValue', key: k }); return store[k] ?? ''; },
|
|
165
|
+
LMSSetValue: (k, v) => { store[k] = v; calls.push({ fn: 'SetValue', key: k, value: v }); return 'true'; },
|
|
166
|
+
LMSCommit: () => 'true',
|
|
167
|
+
LMSGetLastError: () => '0',
|
|
168
|
+
LMSGetErrorString: () => '',
|
|
169
|
+
LMSGetDiagnostic: () => '',
|
|
170
|
+
};
|
|
171
|
+
window.__jsErrors__ = [];
|
|
172
|
+
window.addEventListener('error', e => window.__jsErrors__.push(e.message));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const bugs = [];
|
|
176
|
+
const passes = [];
|
|
177
|
+
const screenshots = {};
|
|
178
|
+
|
|
179
|
+
// ── CHECK 1: Load ───────────────────────────────────────────────────────────
|
|
180
|
+
process.stdout.write(' [1/7] Loading module... ');
|
|
181
|
+
await page.goto(BASE_URL + '/index.html');
|
|
182
|
+
try {
|
|
183
|
+
await page.waitForFunction(() => {
|
|
184
|
+
const el = document.getElementById('__bundler_loading');
|
|
185
|
+
return !el || el.style.display === 'none' || el.textContent === '';
|
|
186
|
+
}, { timeout: 20_000 });
|
|
187
|
+
passes.push({ id: 'CHK-001', title: 'Module loads without fatal errors' });
|
|
188
|
+
console.log('✅ PASS');
|
|
189
|
+
} catch {
|
|
190
|
+
bugs.push({
|
|
191
|
+
id: 'BUG-001', severity: 'critical',
|
|
192
|
+
title: 'Module failed to load',
|
|
193
|
+
desc: 'The module did not finish loading within 20 seconds.',
|
|
194
|
+
steps: ['Open index.html in a browser', 'Wait — the loading spinner never disappears'],
|
|
195
|
+
impact: 'Learner cannot start the module at all.',
|
|
196
|
+
});
|
|
197
|
+
console.log('❌ FAIL');
|
|
198
|
+
}
|
|
199
|
+
screenshots.landing = await screenshot(page, '01-landing');
|
|
200
|
+
|
|
201
|
+
// ── CHECK 2: Title ──────────────────────────────────────────────────────────
|
|
202
|
+
process.stdout.write(' [2/7] Checking title... ');
|
|
203
|
+
const title = await page.title();
|
|
204
|
+
if (title && title.trim().length > 0) {
|
|
205
|
+
passes.push({ id: 'CHK-002', title: `Page title is set: "${title}"` });
|
|
206
|
+
console.log('✅ PASS —', title);
|
|
207
|
+
} else {
|
|
208
|
+
bugs.push({
|
|
209
|
+
id: 'BUG-002', severity: 'medium',
|
|
210
|
+
title: 'Page title is missing or empty',
|
|
211
|
+
desc: 'The <title> tag is empty. LMS systems use this to identify the module.',
|
|
212
|
+
steps: ['Open index.html', 'Check the browser tab — it shows no title'],
|
|
213
|
+
impact: 'LMS may display the module with no name.',
|
|
214
|
+
});
|
|
215
|
+
console.log('❌ FAIL');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── CHECK 3: SCORM API ──────────────────────────────────────────────────────
|
|
219
|
+
process.stdout.write(' [3/7] Checking SCORM API... ');
|
|
220
|
+
await page.waitForTimeout(3000);
|
|
221
|
+
const scormInit = await page.evaluate(() => window.__scorm__?.initialized ?? false);
|
|
222
|
+
if (scormInit) {
|
|
223
|
+
passes.push({ id: 'CHK-003', title: 'SCORM API connected — LMSInitialize called' });
|
|
224
|
+
console.log('✅ PASS');
|
|
225
|
+
} else {
|
|
226
|
+
bugs.push({
|
|
227
|
+
id: 'BUG-003', severity: 'critical',
|
|
228
|
+
title: 'Module never calls LMSInitialize — no SCORM connection',
|
|
229
|
+
desc: 'The module did not call LMSInitialize on load. Without this, the LMS cannot track learner progress, score, or completion.',
|
|
230
|
+
steps: ['Open index.html inside an LMS', 'Check SCORM debug logs — LMSInitialize is never called'],
|
|
231
|
+
impact: 'Score, completion status and time will NOT be recorded in any LMS.',
|
|
232
|
+
});
|
|
233
|
+
console.log('❌ FAIL');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── CHECK 4: JS errors ──────────────────────────────────────────────────────
|
|
237
|
+
process.stdout.write(' [4/7] Checking for JS errors... ');
|
|
238
|
+
const jsErrors = await page.evaluate(() => window.__jsErrors__ || []);
|
|
239
|
+
const errOverlay = await page.evaluate(() => {
|
|
240
|
+
const el = document.getElementById('__bundler_err');
|
|
241
|
+
return el?.textContent?.trim() || '';
|
|
242
|
+
});
|
|
243
|
+
const allErrors = [...new Set([...jsErrors, ...(errOverlay ? [errOverlay.slice(0, 200)] : [])])];
|
|
244
|
+
|
|
245
|
+
if (allErrors.length === 0) {
|
|
246
|
+
passes.push({ id: 'CHK-004', title: 'No JavaScript errors detected' });
|
|
247
|
+
console.log('✅ PASS');
|
|
248
|
+
} else {
|
|
249
|
+
await page.evaluate(() => {
|
|
250
|
+
const lbl = document.createElement('div');
|
|
251
|
+
lbl.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#dc2626;color:white;font:bold 14px sans-serif;padding:8px 16px;z-index:2147483647;text-align:center;';
|
|
252
|
+
lbl.textContent = '⚠️ BUG FOUND: JavaScript Error Detected';
|
|
253
|
+
document.body.prepend(lbl);
|
|
254
|
+
});
|
|
255
|
+
screenshots.jsError = await screenshot(page, '04-js-error');
|
|
256
|
+
bugs.push({
|
|
257
|
+
id: 'BUG-004', severity: 'high',
|
|
258
|
+
title: `JavaScript errors detected (${allErrors.length} error${allErrors.length > 1 ? 's' : ''})`,
|
|
259
|
+
desc: `The module has ${allErrors.length} JavaScript error(s). This can cause silent failures in quiz scoring, animations, or SCORM data reporting.`,
|
|
260
|
+
errors: allErrors,
|
|
261
|
+
steps: ['Open index.html in Chrome', 'Press F12 → Console tab', 'Observe red error messages on load'],
|
|
262
|
+
impact: 'May cause unexpected behaviour, broken interactions, or incorrect score reporting.',
|
|
263
|
+
screenshot: 'jsError',
|
|
264
|
+
});
|
|
265
|
+
console.log('❌ FAIL —', allErrors.length, 'error(s)');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── CHECK 5: Broken images ──────────────────────────────────────────────────
|
|
269
|
+
process.stdout.write(' [5/7] Checking images... ');
|
|
270
|
+
const imgResult = await page.evaluate(() => {
|
|
271
|
+
const imgs = Array.from(document.querySelectorAll('img'));
|
|
272
|
+
return {
|
|
273
|
+
total: imgs.length,
|
|
274
|
+
brokenBundled: imgs.filter(i => (i.src.startsWith('blob:') || i.src.startsWith('data:')) && i.naturalWidth === 0).map(i => i.src.slice(0, 80)),
|
|
275
|
+
brokenExternal: imgs.filter(i => i.src.startsWith('http') && !i.src.includes('localhost') && i.naturalWidth === 0).map(i => i.src),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (imgResult.brokenBundled.length === 0 && imgResult.brokenExternal.length === 0) {
|
|
280
|
+
passes.push({ id: 'CHK-005', title: `All ${imgResult.total} images loaded correctly` });
|
|
281
|
+
console.log('✅ PASS —', imgResult.total, 'images OK');
|
|
282
|
+
} else {
|
|
283
|
+
await page.evaluate(() => {
|
|
284
|
+
Array.from(document.querySelectorAll('img'))
|
|
285
|
+
.filter(img => img.naturalWidth === 0 && img.src.length > 0)
|
|
286
|
+
.forEach(img => {
|
|
287
|
+
const r = img.getBoundingClientRect();
|
|
288
|
+
if (r.width === 0) return;
|
|
289
|
+
const box = document.createElement('div');
|
|
290
|
+
box.style.cssText = `position:fixed;border:3px solid red;background:rgba(220,38,38,0.15);z-index:2147483647;pointer-events:none;`;
|
|
291
|
+
box.style.left = r.left + 'px'; box.style.top = r.top + 'px';
|
|
292
|
+
box.style.width = Math.max(r.width, 60) + 'px'; box.style.height = Math.max(r.height, 40) + 'px';
|
|
293
|
+
document.body.appendChild(box);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
screenshots.brokenImages = await screenshot(page, '05-broken-images');
|
|
297
|
+
|
|
298
|
+
if (imgResult.brokenBundled.length > 0) {
|
|
299
|
+
bugs.push({
|
|
300
|
+
id: 'BUG-005', severity: 'critical',
|
|
301
|
+
title: `${imgResult.brokenBundled.length} bundled image(s) are broken`,
|
|
302
|
+
desc: 'Images that are part of the SCORM package itself are not loading. Assets were not properly included in the ZIP.',
|
|
303
|
+
steps: ['Open index.html', 'Look for blank/empty spaces where images should be'],
|
|
304
|
+
impact: 'Core visual content is missing for all learners.',
|
|
305
|
+
screenshot: 'brokenImages',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (imgResult.brokenExternal.length > 0) {
|
|
309
|
+
bugs.push({
|
|
310
|
+
id: 'BUG-006', severity: 'high',
|
|
311
|
+
title: `${imgResult.brokenExternal.length} external image(s) failed to load`,
|
|
312
|
+
desc: `The module links to ${imgResult.brokenExternal.length} image(s) on external servers. These are either private, deleted, or permissions have expired.`,
|
|
313
|
+
urls: imgResult.brokenExternal,
|
|
314
|
+
steps: ['Open index.html', 'Observe blank spaces', 'Check browser console for 403/404 on external URLs'],
|
|
315
|
+
impact: 'Visual content missing. SCORM packages should never depend on external URLs.',
|
|
316
|
+
screenshot: 'brokenImages',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
console.log('❌ FAIL —', imgResult.brokenBundled.length, 'bundled broken,', imgResult.brokenExternal.length, 'external broken');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── CHECK 6: Navigation ─────────────────────────────────────────────────────
|
|
323
|
+
process.stdout.write(' [6/7] Checking navigation... ');
|
|
324
|
+
const hasStart = await page.evaluate(() => !!document.querySelector('button[aria-label="Start module"], button.land-start-btn'));
|
|
325
|
+
if (hasStart) {
|
|
326
|
+
await page.click('button[aria-label="Start module"], button.land-start-btn', { force: true }).catch(() => {});
|
|
327
|
+
await page.waitForTimeout(2000);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const clicksToAdvance = [];
|
|
331
|
+
for (let i = 0; i < 5; i++) {
|
|
332
|
+
const c = await page.evaluate(() => document.querySelector('.tb-page, [class*="page-num"], [class*="counter"]')?.textContent?.trim() || '');
|
|
333
|
+
clicksToAdvance.push(c);
|
|
334
|
+
await page.evaluate(() => {
|
|
335
|
+
const btn = document.querySelector('button.ctl.next, button[aria-label*="next" i]:not([aria-label*="review" i])');
|
|
336
|
+
if (btn && !btn.disabled) btn.click();
|
|
337
|
+
});
|
|
338
|
+
await page.waitForTimeout(700);
|
|
339
|
+
}
|
|
340
|
+
const afterCounter = await page.evaluate(() => document.querySelector('.tb-page, [class*="page-num"], [class*="counter"]')?.textContent?.trim() || '');
|
|
341
|
+
const allSame = clicksToAdvance.every(c => c === clicksToAdvance[0]);
|
|
342
|
+
|
|
343
|
+
if (!clicksToAdvance[0]) {
|
|
344
|
+
passes.push({ id: 'CHK-006', title: 'Navigation — no page counter found (single-page or custom nav)' });
|
|
345
|
+
console.log('✅ PASS (no counter)');
|
|
346
|
+
} else if (!allSame) {
|
|
347
|
+
passes.push({ id: 'CHK-006', title: `Navigation works — page counter advanced from ${clicksToAdvance[0]} to ${afterCounter}` });
|
|
348
|
+
console.log('✅ PASS —', clicksToAdvance[0], '→', afterCounter);
|
|
349
|
+
} else {
|
|
350
|
+
screenshots.stuck = await screenshot(page, '06-stuck-navigation');
|
|
351
|
+
bugs.push({
|
|
352
|
+
id: 'BUG-007', severity: 'critical',
|
|
353
|
+
title: `Module is stuck — cannot progress past page ${clicksToAdvance[0]}`,
|
|
354
|
+
desc: `After clicking Next 5 times, the page counter never changed from "${clicksToAdvance[0]}".`,
|
|
355
|
+
evidence: clicksToAdvance,
|
|
356
|
+
steps: ['Open index.html', 'Click Start', 'Click the Next button repeatedly', 'Page counter never advances'],
|
|
357
|
+
impact: 'Learner is completely blocked. Do not publish.',
|
|
358
|
+
screenshot: 'stuck',
|
|
359
|
+
});
|
|
360
|
+
console.log('❌ FAIL — stuck at', clicksToAdvance[0]);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── CHECK 7: SCORM completion ───────────────────────────────────────────────
|
|
364
|
+
process.stdout.write(' [7/7] Checking SCORM completion status... ');
|
|
365
|
+
const scormCalls = await page.evaluate(() => window.__scorm__?.calls || []);
|
|
366
|
+
const statusCalls = scormCalls.filter(c => c.key === 'cmi.core.lesson_status');
|
|
367
|
+
const scoreCalls = scormCalls.filter(c => c.key === 'cmi.core.score.raw');
|
|
368
|
+
const finalStatus = statusCalls.length ? statusCalls[statusCalls.length - 1].value : null;
|
|
369
|
+
const finalScore = scoreCalls.length ? scoreCalls[scoreCalls.length - 1].value : null;
|
|
370
|
+
|
|
371
|
+
if (finalStatus) {
|
|
372
|
+
passes.push({ id: 'CHK-007', title: `SCORM status reported: "${finalStatus}"${finalScore ? ` | Score: ${finalScore}` : ''}` });
|
|
373
|
+
console.log('✅ PASS — status:', finalStatus, finalScore ? '| score: ' + finalScore : '');
|
|
374
|
+
} else {
|
|
375
|
+
passes.push({ id: 'CHK-007', title: 'SCORM status not yet set (may require full module completion)' });
|
|
376
|
+
console.log('ℹ️ Not yet set');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
screenshots.final = await screenshot(page, '07-final');
|
|
380
|
+
|
|
381
|
+
await browser.close();
|
|
382
|
+
server.close();
|
|
383
|
+
if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
|
|
384
|
+
|
|
385
|
+
// ── Build & save report ─────────────────────────────────────────────────────
|
|
386
|
+
console.log('\n📄 Building report...');
|
|
387
|
+
const report = buildReport({ title, bugs, passes, screenshots, scormCalls, input: absInput });
|
|
388
|
+
const baseName = path.basename(absInput).replace(/\.zip$/i, '');
|
|
389
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
390
|
+
const outFile = path.join(path.dirname(absInput), `QA-Report-${baseName}-${dateStr}.html`);
|
|
391
|
+
fs.writeFileSync(outFile, report);
|
|
392
|
+
|
|
393
|
+
console.log('\n✅ Done!');
|
|
394
|
+
console.log(' Bugs found: ', bugs.length);
|
|
395
|
+
console.log(' Checks passed:', passes.length);
|
|
396
|
+
console.log(' Report: ', outFile);
|
|
397
|
+
console.log(' Screenshots: ', SS_DIR);
|
|
398
|
+
console.log('');
|
|
399
|
+
|
|
400
|
+
// Try to open the report in browser (best effort)
|
|
401
|
+
try {
|
|
402
|
+
const open = process.platform === 'win32' ? `start "" "${outFile}"` : `open "${outFile}"`;
|
|
403
|
+
execSync(open, { stdio: 'ignore' });
|
|
404
|
+
} catch {}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
main().catch(e => {
|
|
408
|
+
console.error('\nFatal error:', e.message);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
});
|
package/src/report.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function severityColor(s) {
|
|
4
|
+
return { critical: '#dc2626', high: '#d97706', medium: '#2563eb', low: '#16a34a' }[s] || '#6b7280';
|
|
5
|
+
}
|
|
6
|
+
function severityBg(s) {
|
|
7
|
+
return { critical: '#fee2e2', high: '#fef3c7', medium: '#dbeafe', low: '#dcfce7' }[s] || '#f3f4f6';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function buildReport({ title, bugs, passes, screenshots, scormCalls, input }) {
|
|
11
|
+
const now = new Date().toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' });
|
|
12
|
+
const critical = bugs.filter(b => b.severity === 'critical').length;
|
|
13
|
+
const high = bugs.filter(b => b.severity === 'high').length;
|
|
14
|
+
const medium = bugs.filter(b => b.severity === 'medium').length;
|
|
15
|
+
const total = bugs.length;
|
|
16
|
+
const passed = passes.length;
|
|
17
|
+
|
|
18
|
+
const statusCalls = scormCalls.filter(c => c.key === 'cmi.core.lesson_status');
|
|
19
|
+
const scoreCalls = scormCalls.filter(c => c.key === 'cmi.core.score.raw');
|
|
20
|
+
const finalStatus = statusCalls.length ? statusCalls[statusCalls.length - 1].value : '—';
|
|
21
|
+
const finalScore = scoreCalls.length ? scoreCalls[scoreCalls.length - 1].value : '—';
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
|
|
25
|
+
function ssImg(key) {
|
|
26
|
+
if (!screenshots[key]) return '';
|
|
27
|
+
if (!fs.existsSync(screenshots[key])) return '';
|
|
28
|
+
const data = 'data:image/png;base64,' + fs.readFileSync(screenshots[key]).toString('base64');
|
|
29
|
+
return `<div class="screenshot"><img src="${data}" alt="screenshot"><div class="ss-cap">📸 Screenshot captured automatically by QA script</div></div>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const bugsHtml = bugs.map(b => `
|
|
33
|
+
<div class="bug-card">
|
|
34
|
+
<div class="bug-head">
|
|
35
|
+
<span class="badge" style="background:${severityBg(b.severity)};color:${severityColor(b.severity)}">${b.severity.toUpperCase()}</span>
|
|
36
|
+
<span class="bug-id">${b.id}</span>
|
|
37
|
+
<span class="bug-title">${b.title}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="bug-body">
|
|
40
|
+
<div class="section-label">Description</div>
|
|
41
|
+
<p>${b.desc}</p>
|
|
42
|
+
|
|
43
|
+
${b.steps ? `
|
|
44
|
+
<div class="section-label">Steps to Reproduce</div>
|
|
45
|
+
<ol>${b.steps.map(s => `<li>${s}</li>`).join('')}</ol>
|
|
46
|
+
` : ''}
|
|
47
|
+
|
|
48
|
+
${b.urls ? `
|
|
49
|
+
<div class="section-label">Affected URLs</div>
|
|
50
|
+
<div class="code-block">${b.urls.map(u => `<div>❌ ${u}</div>`).join('')}</div>
|
|
51
|
+
` : ''}
|
|
52
|
+
|
|
53
|
+
${b.errors ? `
|
|
54
|
+
<div class="section-label">Error Messages</div>
|
|
55
|
+
<div class="code-block">${b.errors.map(e => `<div>${e}</div>`).join('')}</div>
|
|
56
|
+
` : ''}
|
|
57
|
+
|
|
58
|
+
${b.evidence ? `
|
|
59
|
+
<div class="section-label">Evidence — Page counter after each Next click</div>
|
|
60
|
+
<div class="code-block">${b.evidence.map((c, i) => `<div>Click ${i + 1}: "${c}"</div>`).join('')}</div>
|
|
61
|
+
` : ''}
|
|
62
|
+
|
|
63
|
+
<div class="section-label">Impact</div>
|
|
64
|
+
<div class="impact">${b.impact}</div>
|
|
65
|
+
|
|
66
|
+
${b.screenshot ? ssImg(b.screenshot) : ''}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
`).join('');
|
|
70
|
+
|
|
71
|
+
const passesHtml = passes.map(p => `
|
|
72
|
+
<div class="pass-row">
|
|
73
|
+
<span class="pass-icon">✅</span>
|
|
74
|
+
<span class="pass-id">${p.id}</span>
|
|
75
|
+
<span>${p.title}</span>
|
|
76
|
+
</div>
|
|
77
|
+
`).join('');
|
|
78
|
+
|
|
79
|
+
return `<!DOCTYPE html>
|
|
80
|
+
<html lang="en">
|
|
81
|
+
<head>
|
|
82
|
+
<meta charset="utf-8">
|
|
83
|
+
<title>QA Report — ${title || 'SCORM Module'}</title>
|
|
84
|
+
<style>
|
|
85
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
86
|
+
body { font: 15px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f4f5f7; color: #172b4d; }
|
|
87
|
+
.header { background: linear-gradient(135deg, #1e3a5f, #0052cc); color: white; padding: 36px 48px; }
|
|
88
|
+
.header h1 { font-size: 26px; font-weight: 800; margin-bottom: 6px; }
|
|
89
|
+
.header .sub { opacity: .8; font-size: 14px; margin-top: 4px; }
|
|
90
|
+
.summary { display: flex; gap: 16px; padding: 24px 48px; background: white; border-bottom: 2px solid #e1e4e8; flex-wrap: wrap; }
|
|
91
|
+
.stat { border-radius: 10px; padding: 16px 28px; text-align: center; min-width: 110px; }
|
|
92
|
+
.stat .num { font-size: 36px; font-weight: 900; line-height: 1; }
|
|
93
|
+
.stat .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; margin-top: 4px; opacity: .7; }
|
|
94
|
+
.stat.red { background: #fee2e2; color: #dc2626; }
|
|
95
|
+
.stat.orange{ background: #fef3c7; color: #b45309; }
|
|
96
|
+
.stat.blue { background: #dbeafe; color: #1d4ed8; }
|
|
97
|
+
.stat.green { background: #dcfce7; color: #16a34a; }
|
|
98
|
+
.stat.gray { background: #f3f4f6; color: #374151; }
|
|
99
|
+
.content { max-width: 1000px; margin: 32px auto; padding: 0 24px 60px; }
|
|
100
|
+
h2 { font-size: 18px; font-weight: 700; margin: 32px 0 16px; padding-bottom: 8px; border-bottom: 2px solid #e1e4e8; }
|
|
101
|
+
.bug-card { background: white; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 24px; overflow: hidden; }
|
|
102
|
+
.bug-head { padding: 18px 24px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #f0f0f0; background: #fafafa; }
|
|
103
|
+
.badge { padding: 3px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em; }
|
|
104
|
+
.bug-id { font: 700 13px/1 monospace; color: #6b7280; }
|
|
105
|
+
.bug-title { font-size: 15px; font-weight: 700; }
|
|
106
|
+
.bug-body { padding: 20px 24px; }
|
|
107
|
+
.bug-body p, .bug-body li { font-size: 14px; line-height: 1.7; }
|
|
108
|
+
.bug-body ol { padding-left: 20px; margin: 8px 0; }
|
|
109
|
+
.bug-body li { margin-bottom: 4px; }
|
|
110
|
+
.section-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #6b7280; margin: 16px 0 6px; }
|
|
111
|
+
.section-label:first-child { margin-top: 0; }
|
|
112
|
+
.code-block { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; background: #1e1e2e; color: #cdd6f4; padding: 12px 16px; border-radius: 8px; overflow-x: auto; line-height: 1.8; }
|
|
113
|
+
.code-block div { color: #f38ba8; }
|
|
114
|
+
.impact { background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; padding: 12px 16px; font-size: 14px; color: #92400e; }
|
|
115
|
+
.screenshot { margin-top: 16px; border-radius: 10px; overflow: hidden; border: 1px solid #e1e4e8; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
|
116
|
+
.screenshot img { width: 100%; display: block; }
|
|
117
|
+
.ss-cap { background: #f4f5f7; font-size: 12px; color: #6b7280; padding: 8px 14px; border-top: 1px solid #e1e4e8; }
|
|
118
|
+
.pass-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: white; border-radius: 8px; margin-bottom: 8px; font-size: 14px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
|
|
119
|
+
.pass-icon { font-size: 16px; }
|
|
120
|
+
.pass-id { font: 700 12px monospace; color: #6b7280; min-width: 70px; }
|
|
121
|
+
.scorm-table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
|
122
|
+
.scorm-table th { background: #f4f5f7; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: #6b7280; padding: 10px 16px; text-align: left; }
|
|
123
|
+
.scorm-table td { padding: 10px 16px; font-size: 13px; border-top: 1px solid #f0f0f0; font-family: monospace; }
|
|
124
|
+
.scorm-table tr:hover td { background: #fafafa; }
|
|
125
|
+
footer { text-align: center; padding: 24px; color: #9ca3af; font-size: 12px; border-top: 1px solid #e1e4e8; background: white; margin-top: 40px; }
|
|
126
|
+
</style>
|
|
127
|
+
</head>
|
|
128
|
+
<body>
|
|
129
|
+
|
|
130
|
+
<div class="header">
|
|
131
|
+
<h1>🐛 SCORM QA Bug Report</h1>
|
|
132
|
+
<div class="sub">Module: <strong>${title || 'Unknown'}</strong></div>
|
|
133
|
+
<div class="sub">File: <strong>${input}</strong></div>
|
|
134
|
+
<div class="sub">Tested: <strong>${now}</strong> · Tool: <strong>scorm-qa v1.0.0</strong></div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div class="summary">
|
|
138
|
+
<div class="stat ${total > 0 ? 'red' : 'green'}"><div class="num">${total}</div><div class="lbl">Total Bugs</div></div>
|
|
139
|
+
<div class="stat ${critical > 0 ? 'red' : 'gray'}"><div class="num">${critical}</div><div class="lbl">Critical</div></div>
|
|
140
|
+
<div class="stat ${high > 0 ? 'orange' : 'gray'}"><div class="num">${high}</div><div class="lbl">High</div></div>
|
|
141
|
+
<div class="stat ${medium > 0 ? 'blue' : 'gray'}"><div class="num">${medium}</div><div class="lbl">Medium</div></div>
|
|
142
|
+
<div class="stat green"><div class="num">${passed}</div><div class="lbl">Passed</div></div>
|
|
143
|
+
<div class="stat gray"><div class="num">${finalStatus}</div><div class="lbl">SCORM Status</div></div>
|
|
144
|
+
<div class="stat gray"><div class="num">${finalScore}</div><div class="lbl">Score</div></div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div class="content">
|
|
148
|
+
${total > 0 ? `
|
|
149
|
+
<h2>🔴 Bugs Found (${total})</h2>
|
|
150
|
+
${bugsHtml}
|
|
151
|
+
` : `<h2 style="color:#16a34a">🎉 No bugs found — all checks passed!</h2>`}
|
|
152
|
+
|
|
153
|
+
<h2>✅ Checks Passed (${passed})</h2>
|
|
154
|
+
${passesHtml}
|
|
155
|
+
|
|
156
|
+
${scormCalls.length > 0 ? `
|
|
157
|
+
<h2>📡 SCORM API Call Log</h2>
|
|
158
|
+
<table class="scorm-table">
|
|
159
|
+
<thead><tr><th>#</th><th>Function</th><th>Key</th><th>Value</th></tr></thead>
|
|
160
|
+
<tbody>
|
|
161
|
+
${scormCalls.slice(0, 30).map((c, i) => `
|
|
162
|
+
<tr><td>${i + 1}</td><td>${c.fn}</td><td>${c.key || '—'}</td><td>${c.value || '—'}</td></tr>
|
|
163
|
+
`).join('')}
|
|
164
|
+
${scormCalls.length > 30 ? `<tr><td colspan="4" style="text-align:center;color:#6b7280">... and ${scormCalls.length - 30} more calls</td></tr>` : ''}
|
|
165
|
+
</tbody>
|
|
166
|
+
</table>
|
|
167
|
+
` : ''}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<footer>Generated by scorm-qa · ${now}</footer>
|
|
171
|
+
</body>
|
|
172
|
+
</html>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { buildReport };
|