@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 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
+ }