diff --git a/AGENTS.md b/AGENTS.md index 7ce5f96..ba5d861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/verify.md b/docs/verify.md index 9b6d725..6b11af7 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -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 diff --git a/packages/verify/README.md b/packages/verify/README.md index 6e999ba..a01be5c 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -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. diff --git a/packages/verify/lib/preview.html b/packages/verify/lib/preview.html index f338b7a..1c63ee7 100644 --- a/packages/verify/lib/preview.html +++ b/packages/verify/lib/preview.html @@ -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; } @@ -18,6 +19,7 @@

Preview

+ Opening…
@@ -26,6 +28,7 @@

Preview

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. @@ -46,11 +49,15 @@

Preview

// 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.' } @@ -63,6 +70,8 @@

Preview

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.' }) diff --git a/packages/verify/tests/preview.test.ts b/packages/verify/tests/preview.test.ts index 5ccb197..0832046 100644 --- a/packages/verify/tests/preview.test.ts +++ b/packages/verify/tests/preview.test.ts @@ -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')