Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,12 @@ driver along and the normalizer should not.
passing it on, and 1.2.0, listening once, was killed by the second before its closing line, so
never exited with 0 there (#79). The page sets `src` from the parsed URL's `href`, `http` or
`https` only, as every page that takes a package address from outside does (#71): its own route
needs no check, but CodeQL flagged the text going in, as `js/xss-through-dom`.
needs no check, but CodeQL flagged the text going in, as `js/xss-through-dom`. A Download link
beside the name (#81, the owner's request of 2026-10-23, so that whoever reviews a package can
keep the file they played) takes the same parsed address, and hides once the presence stream
fails, since the server it downloads from has gone. H5P's own Reuse button (`frame export
download-url`) was the other way: it changes how the content looks, takes two clicks, and its
dialog's button is English whatever the content's language.

Not built, deliberately: steps that click through the content and check the xAPI it sends, and a
content digest for reviewing what was generated. A check of library versions against the hub's
Expand Down
3 changes: 2 additions & 1 deletion docs/verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ read the result.
The check cannot say whether the content is right; a person decides that by playing it.
`--preview` checks nothing and opens the package in the real player in the person's own
browser, served from their computer with `Range`, as a static host serves it. It works for any
content type, and a large video starts at once. It ends a few seconds after its last tab closes:
content type, and a large video starts at once. Its page has a Download button for the file. It
ends a few seconds after its last tab closes:

```bash
npx @missing-elements/h5p-verify@latest course.h5p --preview
Expand Down
3 changes: 3 additions & 0 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ The package is served with `Range`, as a static host serves it. The libraries ta
requests, and a video is read by the ranges the player asks for, so a large one starts at once
and nothing is read whole or extracted to disk.

The page has a Download button beside the package's name, so whoever reviews the package can
keep the file they played.

The preview ends a few seconds after its last tab closes, so an agent can start it in the
background and leave it. `--no-open` prints the address instead of opening your default browser.
Exit code 0 when it ends, 2 when it could not start.
Expand Down
11 changes: 10 additions & 1 deletion packages/verify/lib/preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
body { margin: 0; background: Canvas; color: CanvasText; font: 15px/1.4 system-ui, sans-serif; }
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; padding: 12px 16px; border-bottom: 1px solid GrayText; }
h1 { margin: 0; font-size: 1em; overflow-wrap: anywhere; }
#download { padding: 1px 10px; border: 1px solid ButtonBorder; border-radius: 4px; background: ButtonFace; color: ButtonText; text-decoration: none; }
#status { color: GrayText; }
main { max-width: 1024px; margin: 16px auto; padding: 0 16px; }
h5p-player { display: block; min-height: 240px; }
Expand All @@ -18,6 +19,7 @@
<body>
<header>
<h1 id="name">Preview</h1>
<a id="download" aria-describedby="name" hidden>Download</a>
<span id="status" role="status">Opening…</span>
</header>
<main><h5p-player></h5p-player></main>
Expand All @@ -26,6 +28,7 @@ <h1 id="name">Preview</h1>
const settings = JSON.parse(document.getElementById('settings').textContent)
const player = document.querySelector('h5p-player')
const status = document.getElementById('status')
const download = document.getElementById('download')
document.title = `${settings.name} · preview`
document.getElementById('name').textContent = settings.name
// The element as the check sets it: the same libraries, played the same way.
Expand All @@ -46,11 +49,15 @@ <h1 id="name">Preview</h1>
// An error once the content is up leaves it running; before that, the package did not open.
if (player.state !== 'ready') status.textContent = event.detail.message
})
// Only a web address becomes `src`: never a `javascript:` or `data:` URL.
// Only a web address becomes `src`: never a `javascript:` or `data:` URL. The Download link
// takes the same address, so that whoever reviews the package can keep the file they played.
try {
const src = new URL(settings.src, window.location.href)
if (src.protocol === 'http:' || src.protocol === 'https:') {
player.setAttribute('src', src.href)
download.href = src.href
download.download = settings.name
download.hidden = false
} else {
status.textContent = 'Invalid preview source.'
}
Expand All @@ -63,6 +70,8 @@ <h1 id="name">Preview</h1>
const presence = new EventSource('/__preview/events')
presence.addEventListener('error', () => {
presence.close()
// Its server, which the link downloads from, has gone with it.
download.hidden = true
status.textContent = 'The preview has ended. Run the command again to open it.'
})
</script>
Expand Down
19 changes: 19 additions & 0 deletions packages/verify/tests/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,25 @@ describe('h5p-verify --preview', () => {
}
}, 120_000)

it('offers the package for download, until its server has gone', async () => {
const preview = await previewPackage({ file: quiz, port: 0 })
const browser = await launch(undefined)
try {
const page = await browser.newPage()
await page.goto(`${preview.url}/`)
const link = page.getByRole('link', { name: 'Download' })
const [download] = await Promise.all([page.waitForEvent('download'), link.click()])
expect(download.suggestedFilename()).toBe('quiz.h5p')
expect((await readFile(await download.path())).equals(await readFile(quiz))).toBe(true)
preview.close()
await link.waitFor({ state: 'hidden' })
expect(await page.locator('#status').textContent()).toContain('The preview has ended.')
} finally {
preview.close()
await browser.close()
}
}, 60_000)

it('ends by itself when no tab opens it in time', async () => {
const preview = await previewPackage({ file: quiz, port: 0, openWait: 100 })
expect(await preview.closed).toBe('unopened')
Expand Down
Loading