@missing-elements/h5p-verify 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/bin/h5p-verify.mjs +78 -0
- package/lib/page.html +48 -0
- package/lib/verify.mjs +278 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 missing-elements
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @missing-elements/h5p-verify
|
|
2
|
+
|
|
3
|
+
Plays an `.h5p` package in a headless browser through
|
|
4
|
+
[h5p-offline-player](https://github.com/missing-elements/h5p-offline-player) and reports whether
|
|
5
|
+
it works. The last check before a package — generated, edited, repackaged — is handed to anyone.
|
|
6
|
+
|
|
7
|
+
A zip that opens and JSON that parses prove nothing: most broken packages are valid zips that
|
|
8
|
+
fail in the H5P runtime, or start and draw the wrong thing. This runs the real runtime, with no
|
|
9
|
+
server and no account, in a few seconds.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @missing-elements/h5p-verify course.h5p
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pass course.h5p ready in 0.37 s revision sha256:872ee6f732e6a4b7…
|
|
17
|
+
screenshot: course.verify/screenshot.png
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
fail course.h5p state error
|
|
22
|
+
bad-archive: This package contains no libraries, only content. … Missing: H5P.QuestionSet-1.20
|
|
23
|
+
fix: add the library folders (H5P.QuestionSet-1.20), or --libraries hub if the destination site supplies libraries
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Exit code 0 means it plays (there may be warnings), 1 that it does not, 2 that the check could
|
|
27
|
+
not run. `report.json` and `screenshot.png` land in `<package>.verify/` beside the package.
|
|
28
|
+
|
|
29
|
+
## What it checks
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| The archive | indexed by the player: `h5p.json`, entry names, the libraries it declares against the folders it carries |
|
|
34
|
+
| Libraries | missing ones are named; `--libraries hub` or `--libraries <url>` supplies them the way a host would |
|
|
35
|
+
| Boot | the runtime reaches `ready`, and how long that took |
|
|
36
|
+
| Errors | an uncaught error while booting fails the check even when the content comes up around it; one after `ready` is a warning, since content types throw non-fatal exceptions routinely |
|
|
37
|
+
| Rendering | the content drew something: the frame's root has a height and elements |
|
|
38
|
+
| Requests | anything the frame asked for and did not get, such as a media file `content.json` names that is not in the archive |
|
|
39
|
+
| CSP | an outside origin the frame's policy blocked |
|
|
40
|
+
| Provenance | the `revision` every xAPI statement from this build will carry |
|
|
41
|
+
|
|
42
|
+
What it does not check: whether the content is correct, suited to its learners, accessible, or
|
|
43
|
+
properly licensed. Look at the screenshot; a package can start cleanly and still be laid out
|
|
44
|
+
wrong.
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| | |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `--libraries hub\|<url>` | for a package exported without library folders, what h5p.com and h5p.org do by default |
|
|
51
|
+
| `--out <dir>` | where the report and screenshot go |
|
|
52
|
+
| `--browser <path>` | a Chromium-based browser; otherwise Chrome, then Edge, then Playwright's own Chromium |
|
|
53
|
+
| `--ready <s>` | time allowed to reach `ready`, default 60 |
|
|
54
|
+
| `--settle <s>` | time watched after `ready` for late errors, default 3 |
|
|
55
|
+
| `--json` | the report as JSON on stdout |
|
|
56
|
+
| `-q` | the verdict line only |
|
|
57
|
+
|
|
58
|
+
Needs Node 20 or later and a Chromium-based browser. Chrome or Edge on the machine is used
|
|
59
|
+
if there is one; otherwise `npx playwright install chromium` fetches one (about 150 MB, once).
|
|
60
|
+
Headless, so no display is needed.
|
|
61
|
+
|
|
62
|
+
## From code
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import { verifyPackage, summarize } from '@missing-elements/h5p-verify'
|
|
66
|
+
|
|
67
|
+
const report = await verifyPackage({ file: 'course.h5p', out: 'report/' })
|
|
68
|
+
console.log(summarize(report))
|
|
69
|
+
if (report.verdict !== 'pass') process.exit(1)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## For agents
|
|
73
|
+
|
|
74
|
+
The repository ships an [agent skill](https://agentskills.io), `skills/h5p-verify`, telling an AI
|
|
75
|
+
agent that generates H5P content when to run this and how to read what comes back:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx skills add missing-elements/h5p-offline-player --skill h5p-verify
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
or copy the folder into your agent's skills directory.
|
|
82
|
+
|
|
83
|
+
## Licence
|
|
84
|
+
|
|
85
|
+
MIT. It runs the player, whose H5P runtime is GPL-3.0; see the player's
|
|
86
|
+
[NOTICE.md](https://github.com/missing-elements/h5p-offline-player/blob/main/packages/player/NOTICE.md).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { VerifyError, summarize, verifyPackage } from '../lib/verify.mjs'
|
|
5
|
+
|
|
6
|
+
const HELP = `Usage: h5p-verify <package.h5p> [options]
|
|
7
|
+
|
|
8
|
+
Plays the package in a headless browser through h5p-offline-player and reports whether it
|
|
9
|
+
works. Exit code 0: it plays (warnings possible). 1: it does not. 2: could not run.
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--libraries hub|<url> supply libraries for a package exported without its own
|
|
13
|
+
--out <dir> where report.json and screenshot.png go
|
|
14
|
+
(default: <package>.verify/ beside the package)
|
|
15
|
+
--browser <path> a Chromium-based browser; Chrome, Edge or Playwright's found otherwise
|
|
16
|
+
--ready <seconds> time allowed to reach ready (default 60)
|
|
17
|
+
--settle <seconds> time watched after ready for late errors (default 3)
|
|
18
|
+
--json print the report as JSON instead of the summary
|
|
19
|
+
-q, --quiet only the verdict line
|
|
20
|
+
-h, --help
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
let args
|
|
24
|
+
try {
|
|
25
|
+
args = parseArgs({
|
|
26
|
+
allowPositionals: true,
|
|
27
|
+
options: {
|
|
28
|
+
libraries: { type: 'string' },
|
|
29
|
+
out: { type: 'string' },
|
|
30
|
+
browser: { type: 'string' },
|
|
31
|
+
ready: { type: 'string' },
|
|
32
|
+
settle: { type: 'string' },
|
|
33
|
+
json: { type: 'boolean' },
|
|
34
|
+
quiet: { type: 'boolean', short: 'q' },
|
|
35
|
+
help: { type: 'boolean', short: 'h' }
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
40
|
+
console.error(HELP)
|
|
41
|
+
process.exit(2)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [file] = args.positionals
|
|
45
|
+
if (args.values.help || !file) {
|
|
46
|
+
console.log(HELP)
|
|
47
|
+
process.exit(args.values.help ? 0 : 2)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const seconds = (value, fallback) => {
|
|
51
|
+
if (value === undefined) return fallback
|
|
52
|
+
const number = Number(value)
|
|
53
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
54
|
+
console.error(`Not a number of seconds: ${value}`)
|
|
55
|
+
process.exit(2)
|
|
56
|
+
}
|
|
57
|
+
return number * 1000
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const out = args.values.out ?? join(dirname(resolve(file)), `${basename(file, '.h5p')}.verify`)
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const report = await verifyPackage({
|
|
64
|
+
file,
|
|
65
|
+
libraries: args.values.libraries,
|
|
66
|
+
out,
|
|
67
|
+
browser: args.values.browser,
|
|
68
|
+
readyTimeout: seconds(args.values.ready, 60_000),
|
|
69
|
+
settle: seconds(args.values.settle, 3_000)
|
|
70
|
+
})
|
|
71
|
+
if (args.values.json) console.log(JSON.stringify(report, null, 2))
|
|
72
|
+
else if (args.values.quiet) console.log(summarize(report).split('\n')[0])
|
|
73
|
+
else console.log(summarize(report))
|
|
74
|
+
process.exit(report.verdict === 'pass' ? 0 : 1)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(error instanceof VerifyError ? error.message : error)
|
|
77
|
+
process.exit(2)
|
|
78
|
+
}
|
package/lib/page.html
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>h5p-verify</title>
|
|
6
|
+
<script type="module" src="/h5p-player.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
body { margin: 0; padding: 16px; background: #fff; font: 14px system-ui, sans-serif; }
|
|
9
|
+
h5p-player { display: block; width: 1024px; min-height: 240px; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<input type="file" id="file" hidden />
|
|
14
|
+
<h5p-player auto-resize></h5p-player>
|
|
15
|
+
<script>
|
|
16
|
+
// What the driver reads back: every state, error and statement the element reported,
|
|
17
|
+
// stamped with the page's clock so the report can say how long the boot took.
|
|
18
|
+
const report = (window.__verify = { started: null, states: [], errors: [], xapi: [] })
|
|
19
|
+
const player = document.querySelector('h5p-player')
|
|
20
|
+
const libraries = new URLSearchParams(location.search).get('libraries')
|
|
21
|
+
if (libraries) player.setAttribute('libraries', libraries)
|
|
22
|
+
|
|
23
|
+
player.addEventListener('statechange', (event) => {
|
|
24
|
+
report.states.push({ state: event.detail.state, at: performance.now() })
|
|
25
|
+
})
|
|
26
|
+
player.addEventListener('error', (event) => {
|
|
27
|
+
report.errors.push({
|
|
28
|
+
code: event.detail.code,
|
|
29
|
+
message: event.detail.message,
|
|
30
|
+
missingLibraries: event.detail.missingLibraries ?? null,
|
|
31
|
+
// A runtime error once the content is up leaves the state at `ready`: the content
|
|
32
|
+
// kept running. Before that it is the boot failing.
|
|
33
|
+
afterReady: player.state === 'ready',
|
|
34
|
+
at: performance.now()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
player.addEventListener('xapi', (event) => {
|
|
38
|
+
report.xapi.push(event.detail.verb ?? null)
|
|
39
|
+
})
|
|
40
|
+
document.querySelector('#file').addEventListener('change', (event) => {
|
|
41
|
+
const [file] = event.target.files
|
|
42
|
+
if (!file) return
|
|
43
|
+
report.started = performance.now()
|
|
44
|
+
player.file = file
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
package/lib/verify.mjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { basename, dirname, extname, join, normalize, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { chromium } from 'playwright-core'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Plays a package the way a learner would — the real element, the real Service Worker, a real
|
|
11
|
+
* browser — and reports what happened. The package is handed to the element as a picked file,
|
|
12
|
+
* the same path the installable app uses, so nothing here needs `Range` support or a network.
|
|
13
|
+
*
|
|
14
|
+
* What the report can say: the package indexed (or which libraries it lacks), the runtime
|
|
15
|
+
* booted, nothing threw before `ready`, the content drew something, and what the frame asked
|
|
16
|
+
* for that was not there. What it cannot say: whether the content is any good.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url)
|
|
20
|
+
const here = fileURLToPath(new URL('.', import.meta.url))
|
|
21
|
+
const { version } = require('../package.json')
|
|
22
|
+
|
|
23
|
+
const MIME = {
|
|
24
|
+
'.html': 'text/html; charset=utf-8',
|
|
25
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
26
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
27
|
+
'.css': 'text/css; charset=utf-8',
|
|
28
|
+
'.json': 'application/json',
|
|
29
|
+
'.woff2': 'font/woff2',
|
|
30
|
+
'.woff': 'font/woff',
|
|
31
|
+
'.ttf': 'font/ttf',
|
|
32
|
+
'.svg': 'image/svg+xml',
|
|
33
|
+
'.png': 'image/png',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
36
|
+
'.md': 'text/markdown; charset=utf-8'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} VerifyOptions
|
|
41
|
+
* @property {string} file the `.h5p` to play
|
|
42
|
+
* @property {string} [libraries] `hub`, or the URL of a bundle, for a package without library folders
|
|
43
|
+
* @property {string} [out] where `report.json` and `screenshot.png` go; nothing is written without it
|
|
44
|
+
* @property {string} [browser] a Chromium-based browser executable; found automatically otherwise
|
|
45
|
+
* @property {number} [readyTimeout] ms allowed to reach `ready`; default 60 000
|
|
46
|
+
* @property {number} [settle] ms watched after `ready` for late errors; default 3 000
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {VerifyOptions} options
|
|
51
|
+
*/
|
|
52
|
+
export async function verifyPackage(options) {
|
|
53
|
+
const file = resolve(options.file)
|
|
54
|
+
const size = (await stat(file).catch(() => null))?.size
|
|
55
|
+
if (size === undefined) throw new VerifyError(`No such file: ${file}`)
|
|
56
|
+
const readyTimeout = options.readyTimeout ?? 60_000
|
|
57
|
+
const settle = options.settle ?? 3_000
|
|
58
|
+
|
|
59
|
+
const server = await serve(await playerDist())
|
|
60
|
+
const browser = await launch(options.browser)
|
|
61
|
+
try {
|
|
62
|
+
const context = await browser.newContext({ viewport: { width: 1100, height: 800 } })
|
|
63
|
+
// Runs in every document, the frame included: a CSP violation fires an event in the document
|
|
64
|
+
// it happens in, and nothing else reports it.
|
|
65
|
+
await context.addInitScript(() => {
|
|
66
|
+
const list = []
|
|
67
|
+
Object.defineProperty(window, '__csp', { value: list })
|
|
68
|
+
document.addEventListener('securitypolicyviolation', (event) => {
|
|
69
|
+
list.push({ directive: event.violatedDirective, blocked: event.blockedURI })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
const page = await context.newPage()
|
|
73
|
+
|
|
74
|
+
const consoleErrors = []
|
|
75
|
+
const failedRequests = []
|
|
76
|
+
page.on('console', (message) => {
|
|
77
|
+
// A failed load is on the response listener, with the probe 404s filtered out; the
|
|
78
|
+
// console's copy of it names no URL and would only repeat it.
|
|
79
|
+
if (message.type() === 'error' && !message.text().startsWith('Failed to load resource')) consoleErrors.push(message.text())
|
|
80
|
+
})
|
|
81
|
+
page.on('response', (response) => {
|
|
82
|
+
const url = response.url()
|
|
83
|
+
// The runtime probes `library.json` under the versioned and the unversioned folder name and
|
|
84
|
+
// takes whichever answers; one of the two is always a 404, and it is load-bearing.
|
|
85
|
+
if (response.status() >= 400 && url.startsWith(server.url) && !url.endsWith('/library.json')) {
|
|
86
|
+
failedRequests.push({ url: url.slice(server.url.length), status: response.status() })
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
page.on('requestfailed', (request) => {
|
|
90
|
+
const url = request.url()
|
|
91
|
+
const error = request.failure()?.errorText ?? 'failed'
|
|
92
|
+
// An abort is the requester's own doing — a media element drops a range request every
|
|
93
|
+
// time it seeks — and says nothing about the package.
|
|
94
|
+
if (url.startsWith(server.url) && error !== 'net::ERR_ABORTED') failedRequests.push({ url: url.slice(server.url.length), error })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const query = options.libraries ? `?libraries=${encodeURIComponent(options.libraries)}` : ''
|
|
98
|
+
await page.goto(`${server.url}/${query}`)
|
|
99
|
+
await page.setInputFiles('#file', file)
|
|
100
|
+
|
|
101
|
+
let timedOut = false
|
|
102
|
+
await page
|
|
103
|
+
.waitForFunction(() => window.__verify.states.some((s) => s.state === 'ready' || s.state === 'error'), null, { timeout: readyTimeout })
|
|
104
|
+
.catch(() => {
|
|
105
|
+
timedOut = true
|
|
106
|
+
})
|
|
107
|
+
if (!timedOut) await page.waitForTimeout(settle)
|
|
108
|
+
|
|
109
|
+
const collected = await page.evaluate(() => {
|
|
110
|
+
const player = document.querySelector('h5p-player')
|
|
111
|
+
return { ...window.__verify, state: player.state, revision: player.revision }
|
|
112
|
+
})
|
|
113
|
+
const frame = page.frames().find((candidate) => candidate.url().includes('/h5p/frame/'))
|
|
114
|
+
const rendered = frame
|
|
115
|
+
? await frame
|
|
116
|
+
.evaluate(() => {
|
|
117
|
+
const root = document.querySelector('#h5p-root')
|
|
118
|
+
const rect = root?.getBoundingClientRect()
|
|
119
|
+
return {
|
|
120
|
+
height: rect ? Math.round(rect.height) : 0,
|
|
121
|
+
elements: root ? root.querySelectorAll('*').length : 0,
|
|
122
|
+
text: (root?.innerText ?? '').trim().length,
|
|
123
|
+
instances: window.H5P?.instances?.length ?? null
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
.catch(() => null)
|
|
127
|
+
: null
|
|
128
|
+
const csp = frame ? await frame.evaluate(() => window.__csp ?? []).catch(() => []) : []
|
|
129
|
+
|
|
130
|
+
let screenshot = null
|
|
131
|
+
if (options.out && collected.state === 'ready') {
|
|
132
|
+
await mkdir(options.out, { recursive: true })
|
|
133
|
+
screenshot = join(options.out, 'screenshot.png')
|
|
134
|
+
await page.locator('h5p-player').screenshot({ path: screenshot })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ready = collected.states.find((s) => s.state === 'ready')
|
|
138
|
+
const report = {
|
|
139
|
+
tool: `h5p-verify ${version}`,
|
|
140
|
+
file,
|
|
141
|
+
size,
|
|
142
|
+
libraries: options.libraries ?? null,
|
|
143
|
+
verdict: 'pass',
|
|
144
|
+
reasons: [],
|
|
145
|
+
warnings: [],
|
|
146
|
+
state: collected.state,
|
|
147
|
+
readyMs: ready && collected.started !== null ? Math.round(ready.at - collected.started) : null,
|
|
148
|
+
revision: collected.revision ?? null,
|
|
149
|
+
errors: collected.errors.map(({ at, ...error }) => error),
|
|
150
|
+
rendered,
|
|
151
|
+
xapi: [...new Set(collected.xapi)],
|
|
152
|
+
csp,
|
|
153
|
+
failedRequests,
|
|
154
|
+
console: consoleErrors.filter((text) => !collected.errors.some((error) => text.includes(error.message))).slice(0, 20),
|
|
155
|
+
screenshot
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (timedOut) {
|
|
159
|
+
report.verdict = 'fail'
|
|
160
|
+
report.reasons.push(`did not reach ready within ${readyTimeout / 1000} s; last state: ${collected.state}`)
|
|
161
|
+
} else if (collected.state !== 'ready') {
|
|
162
|
+
report.verdict = 'fail'
|
|
163
|
+
for (const error of report.errors.filter((e) => !e.afterReady)) report.reasons.push(`${error.code}: ${error.message}`)
|
|
164
|
+
if (report.reasons.length === 0) report.reasons.push(`state is ${collected.state}`)
|
|
165
|
+
} else if (!rendered || rendered.height < 1 || rendered.elements === 0) {
|
|
166
|
+
report.verdict = 'fail'
|
|
167
|
+
report.reasons.push('the content started but drew nothing: the frame is empty')
|
|
168
|
+
}
|
|
169
|
+
// An uncaught error while the runtime boots is a defect in the package even when the content
|
|
170
|
+
// comes up around it — a library listed that is not a runtime library, a script that reaches
|
|
171
|
+
// for something absent — and the part that threw is missing from what was drawn. One after
|
|
172
|
+
// `ready` is the content running, and content types throw non-fatal exceptions routinely.
|
|
173
|
+
const booting = report.errors.filter((e) => e.code === 'runtime' && !e.afterReady)
|
|
174
|
+
if (report.verdict === 'pass' && booting.length > 0) {
|
|
175
|
+
report.verdict = 'fail'
|
|
176
|
+
report.reasons.push(`${booting.length} uncaught error${booting.length === 1 ? '' : 's'} while booting: ${dedupe(booting.map((e) => e.message)).join('; ')}`)
|
|
177
|
+
}
|
|
178
|
+
for (const message of dedupe(report.errors.filter((e) => e.afterReady).map((e) => e.message))) report.warnings.push(`runtime error after ready: ${message}`)
|
|
179
|
+
for (const { url, status, error } of failedRequests) report.warnings.push(`request failed: ${url} (${status ?? error})`)
|
|
180
|
+
for (const { directive, blocked } of csp) report.warnings.push(`blocked by the frame's CSP: ${blocked} (${directive})`)
|
|
181
|
+
for (const text of report.console) report.warnings.push(`console error: ${text.slice(0, 200)}`)
|
|
182
|
+
|
|
183
|
+
if (options.out) {
|
|
184
|
+
await mkdir(options.out, { recursive: true })
|
|
185
|
+
await writeFile(join(options.out, 'report.json'), `${JSON.stringify(report, null, 2)}\n`)
|
|
186
|
+
}
|
|
187
|
+
return report
|
|
188
|
+
} finally {
|
|
189
|
+
await browser.close()
|
|
190
|
+
server.close()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export class VerifyError extends Error {}
|
|
195
|
+
|
|
196
|
+
/** The distinct messages, each with how often it occurred: `… (×4)`. */
|
|
197
|
+
function dedupe(messages) {
|
|
198
|
+
const counts = new Map()
|
|
199
|
+
for (const message of messages) counts.set(message, (counts.get(message) ?? 0) + 1)
|
|
200
|
+
return [...counts].map(([message, count]) => (count > 1 ? `${message} (×${count})` : message))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The player's `dist/`, from the installed package; the workspace needs it built first. */
|
|
204
|
+
async function playerDist() {
|
|
205
|
+
const element = require.resolve('@missing-elements/h5p-offline-player')
|
|
206
|
+
const dist = dirname(element)
|
|
207
|
+
if (!existsSync(join(dist, 'h5p-sw.js')) || !existsSync(join(dist, 'frame-assets', 'h5p.css'))) {
|
|
208
|
+
throw new VerifyError(`The player is not built at ${dist} — run \`pnpm build\` in the workspace, or reinstall the package`)
|
|
209
|
+
}
|
|
210
|
+
return dist
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A static server for the player and the test page, on a free localhost port. */
|
|
214
|
+
function serve(dist) {
|
|
215
|
+
const page = join(here, 'page.html')
|
|
216
|
+
const server = createServer(async (request, response) => {
|
|
217
|
+
const path = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname)
|
|
218
|
+
const target = path === '/' ? page : join(dist, normalize(path).replace(/^(\.\.[/\\])+/, ''))
|
|
219
|
+
if (target !== page && !target.startsWith(dist)) {
|
|
220
|
+
response.writeHead(403).end()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const body = await readFile(target)
|
|
225
|
+
response.writeHead(200, {
|
|
226
|
+
'Content-Type': MIME[extname(target)] ?? 'application/octet-stream',
|
|
227
|
+
'Content-Length': body.length,
|
|
228
|
+
'Cache-Control': 'no-store'
|
|
229
|
+
})
|
|
230
|
+
response.end(body)
|
|
231
|
+
} catch {
|
|
232
|
+
response.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found')
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
return new Promise((resolveServer) => {
|
|
236
|
+
server.listen(0, '127.0.0.1', () => {
|
|
237
|
+
const { port } = /** @type {import('node:net').AddressInfo} */ (server.address())
|
|
238
|
+
resolveServer({ url: `http://127.0.0.1:${port}`, close: () => server.close() })
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A Chromium: the one named, else Chrome or Edge installed on the machine, else Playwright's
|
|
245
|
+
* own. Chrome and Edge first because they are usually there and cost no download.
|
|
246
|
+
*/
|
|
247
|
+
async function launch(executablePath) {
|
|
248
|
+
const attempts = executablePath
|
|
249
|
+
? [{ executablePath }]
|
|
250
|
+
: [{ channel: 'chrome' }, { channel: 'msedge' }, {}]
|
|
251
|
+
const failures = []
|
|
252
|
+
for (const attempt of attempts) {
|
|
253
|
+
try {
|
|
254
|
+
return await chromium.launch({ headless: true, ...attempt })
|
|
255
|
+
} catch (error) {
|
|
256
|
+
failures.push(error instanceof Error ? error.message.split('\n')[0] : String(error))
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
throw new VerifyError(
|
|
260
|
+
'No browser could be started. Install Google Chrome or Microsoft Edge, run `npx playwright install chromium`, ' +
|
|
261
|
+
`or pass --browser <path>.\n ${failures.join('\n ')}`
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A one-line, human-readable account of a report, for the command and for logs. */
|
|
266
|
+
export function summarize(report) {
|
|
267
|
+
const name = basename(report.file)
|
|
268
|
+
const took = report.readyMs !== null ? `ready in ${(report.readyMs / 1000).toFixed(2)} s` : `state ${report.state}`
|
|
269
|
+
const lines = [`${report.verdict} ${name} ${took}${report.revision ? ` revision ${report.revision.slice(0, 23)}…` : ''}`]
|
|
270
|
+
for (const reason of report.reasons) lines.push(` ${reason}`)
|
|
271
|
+
const missing = report.errors.find((error) => error.missingLibraries)?.missingLibraries
|
|
272
|
+
if (missing) {
|
|
273
|
+
lines.push(` fix: add the library folders (${missing.folders.join(', ')}), or --libraries hub if the destination site supplies libraries`)
|
|
274
|
+
}
|
|
275
|
+
for (const warning of report.warnings) lines.push(` warning: ${warning}`)
|
|
276
|
+
if (report.screenshot) lines.push(` screenshot: ${report.screenshot}`)
|
|
277
|
+
return lines.join('\n')
|
|
278
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@missing-elements/h5p-verify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Plays an .h5p package in a headless browser through h5p-offline-player and reports whether it works: start-up, errors, missing libraries, a screenshot. The last check before a generated package is handed to anyone.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/missing-elements/h5p-offline-player.git",
|
|
9
|
+
"directory": "packages/verify"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/missing-elements/h5p-offline-player/tree/main/packages/verify#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/missing-elements/h5p-offline-player/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"h5p-verify": "./bin/h5p-verify.mjs"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./lib/verify.mjs",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"lib"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"h5p",
|
|
32
|
+
"verify",
|
|
33
|
+
"test",
|
|
34
|
+
"playwright",
|
|
35
|
+
"headless",
|
|
36
|
+
"elearning",
|
|
37
|
+
"cli",
|
|
38
|
+
"agent-skill"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"playwright-core": "^1.50.0",
|
|
42
|
+
"@missing-elements/h5p-offline-player": "^0.1.9"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.13.0",
|
|
46
|
+
"@zip.js/zip.js": "^2.17.0",
|
|
47
|
+
"typescript": "^5.7.3",
|
|
48
|
+
"vitest": "^5.0.1"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
|
|
55
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
56
|
+
}
|
|
57
|
+
}
|