From b86e7e0f982ca7e340cf684dbf9cf2412d045994 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Fri, 23 Oct 2026 19:57:44 +0300 Subject: [PATCH] feat(verify): a Download button on the preview page The preview showed the package's name and nothing to take the file away with, so whoever reviewed a package there had to find the folder the command ran in; in the h5p-author skill's flow, an agent chose it. The bar now has a Download button beside the name, which saves the .h5p under its own name. The link takes the address the page already parsed and checked for the player's src, http or https only, from the parsed URL's href (#71). It hides once the presence stream fails, when the page says the preview has ended, since the server it downloads from has gone. A test clicks it in a browser and compares the saved file with the package byte for byte, then ends the preview and waits for the link to go: it times out on the old page, and passes. Closes #81. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 ++++++- docs/verify.md | 3 ++- packages/verify/README.md | 3 +++ packages/verify/lib/preview.html | 11 ++++++++++- packages/verify/tests/preview.test.ts | 19 +++++++++++++++++++ 5 files changed, 40 insertions(+), 3 deletions(-) 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')