diff --git a/src/BloomE2E/AUTOMATION-DEBT.md b/src/BloomE2E/AUTOMATION-DEBT.md index af764bd2cdd2..0f538fea9729 100644 --- a/src/BloomE2E/AUTOMATION-DEBT.md +++ b/src/BloomE2E/AUTOMATION-DEBT.md @@ -21,12 +21,11 @@ and ask its author. | Pull request | Branch | What it pays down | | --- | --- | --- | -| #8300 | `BL-16799-collection-languages` | The `e2e/setCollectionLanguages` hook, so no test composes `.bloomCollection` XML. | | not yet open | `e2e-private-user-settings` | Every Bloom a test launches keeps its user settings in a folder of its own, named on the command line, so a run starts from defaults and its settings die with its temp folder. | | not yet open | `e2e-real-library-login` | A test can sign in to dev.bloomlibrary.org for real, with a test account whose credentials the run supplies, so the upload cases can run to the end. Branches off the one above. | -Three of these also add entries of their own, for the debt that is left after the fix. The -stack replaces PR #8276, which did all of this at once. +The second of these branches off the first. Both add entries of their own, for the debt that +is left after the fix. --- @@ -40,18 +39,13 @@ force-foreground trick. Fix direction: move these surfaces to the web UI (the te direction anyway), or expose each dialog's WebView2 on a discoverable CDP port. (Promoted from PAPERCUTS 2026-07-11.) -seen again 2026-09-01 (Test Case ID 169, `publish-text-languages.spec.ts`): the case -turns on which languages the collection has, and there is no API for that either. -`collectionSettings/changeLanguage` is not one: its only listener is the open WinForms -`CollectionSettingsDialog` (`CollectionSettingsDialog.cs:368`), so a POST to it while -the dialog is closed does nothing. The test therefore changes a collection language by -stopping Bloom, rewriting the `.bloomCollection`, and starting again, which is what the -new `bloomApp.restart(betweenStopAndStart)` fixture method is for. Each restart costs -about six seconds and loses whatever the editor had not yet saved. - -being fixed on `BL-16799-collection-languages` (#8300): the `e2e/setCollectionLanguages` hook -does the work of the Settings dialog's OK button, so no test composes `.bloomCollection` -XML. The dialog itself stays on this entry: nothing can drive or screenshot it. +seen again 2026-09-01, and the collection-languages half of it is now fixed: tests set +the collection's languages through the `e2e/setCollectionLanguages` hook, which does +the same work as clicking OK in the Collection Settings dialog, and +`helpers/collection.ts setCollectionLanguages` wraps it with the restart Bloom still +needs (about six seconds, and it loses whatever the editor had not yet saved). No test +composes `.bloomCollection` XML any more. What remains is the dialog itself: nothing +can drive or screenshot the Settings UI, so the journey test for it cannot be written. seen again 2026-09-01 (Test Case ID 349, `duplicate-page.spec.ts`): "Duplicate Page Many Times..." asks how many copies in `DuplicateManyDialog`, which is `WireUpForWinforms`, so the @@ -128,7 +122,12 @@ established 2026-09-01: - **Leaving the variable unset does not mean "no dev server".** A dev build probes port 5173 by itself (`ReactControl.TryGetActiveViteDevPort`), so a developer's own dev server silently decides what the suite tests, and Bloom offers no option that means "ignore any dev server" - (`--vite-port` rejects 0, and `ValidateStartupVitePort` requires the port to answer). + (`--vite-port` rejects 0, and `ValidateStartupVitePort` requires the port to answer). This is + what went wrong on 2026-09-02: the variable was unset, so a run served a day-old bundle and + `duplicate-page.spec.ts` reported a new test id as absent. `--vite-port` itself honours any + port. `ReactControl.ReplaceViteDevOrigin` rewrites the literal `http://localhost:5173` in all + three Edit frames, the two `*.vite-dev.pug` files included, and a run on 5199 on 2026-09-03 + loaded the shell, the toolbox and the page list from 5199. What remains: the fixture neither starts a dev server of its own nor checks that `output/browser` is newer than `src/BloomBrowserUI`, so a run with the variable unset can still test a stale @@ -239,6 +238,25 @@ shows a `.bloom-page`. Two page adds in a row therefore lost the second one. helpers no longer act early; the production endpoints still reply success to a request they dropped. +## Bloom names a dropped language differently on CI and on a developer machine (BL-16806) + +`publish-text-languages.spec.ts` used to drop a language by rewriting the `.bloomCollection`, +and it then expected the publish list to show that language's own name for itself, "español". +On CI that assertion failed every time, with `Expected: español Received: espagnol` — French +for Spanish. Everything else about the row was right. + +The cause is known, and BL-16806 is the card that fixes it. Bloom asks LibPalaso for the name of +the dropped language "in" the collection's metadata language, French here. LibPalaso honors such +a request only where it can find a native ICU library, and Bloom ships icu.net but no +`icuuc.dll`, so the answer depends on the machine, not on the run: the CI runner answers +"espagnol", and a developer machine ignores the request and gives the autonym "español". It is +a real difference in what Bloom shows a user, not a test problem. + +No test on this branch covers it any more. The test now drops the language through +`e2e/setCollectionLanguages`, the code the Settings dialog's OK button runs, which keeps the +language's collection name, so the list reads "Spanish" on every machine and the lookup that +differs is never reached. Recorded here so the coverage loss is visible; the defect itself +belongs to BL-16806. (Found 2026-09-01, diagnosed on master 2026-09-02.) ## Filling a text box directly leaves part of the old text behind A `.bloom-editable` is a CKEditor surface, and Playwright's `fill()` on one leaves a @@ -407,29 +425,6 @@ meantime: run small and stay red, or stay large until the tests are fixed. developer chose to record the plan here rather than carry a red suite. The code is not in the history, so rebuilding it from this entry is part of the job.) -## A Vite dev server only reaches the whole UI on port 5173 - -`--vite-port` tells Bloom's shell which dev server to load the front end from, but two of the -Edit tab's frames ignore it. `bookEdit/pageThumbnailList/pageThumbnailList.vite-dev.pug` and -`bookEdit/toolbox/toolbox.vite-dev.pug` write `http://localhost:5173/...` into every import -they emit, so on any other port the page list and the toolbox load nothing and come up empty. - -That failure looks like the feature being missing, not like a port problem. A run on port 5199 -failed `duplicate-page.spec.ts` on 2026-09-02 with "waiting for -getByTestId('duplicate-page-button') to be visible", 30 seconds, because `#PageControls` had -never been filled. Nothing in the message points at the dev server. - -The same run showed the second half of it: `BLOOM_E2E_VITE_PORT` was unset, so Bloom fell back -to probing 5173 by itself, found nothing there, and served the built `output/browser` instead. -That bundle was a day old, so the suite silently tested yesterday's front end and reported the -new test id as absent. - -So both halves say the same thing: **serve the dev server on 5173 and set -`BLOOM_E2E_VITE_PORT=5173`.** Fix direction: emit the port into those two pug files the way the -shell gets it, so `--vite-port` means what it says; and give Bloom an option that means "ignore -any dev server", so a run can state which front end it is testing rather than inherit it from -the machine. (Found 2026-09-02.) - ## Typing in a text box raises no key events `typeInGroup` puts the whole string in with `keyboard.insertText`, which raises `input` diff --git a/src/BloomE2E/README.md b/src/BloomE2E/README.md index 7e0c94c7ac90..c648ced38cdd 100644 --- a/src/BloomE2E/README.md +++ b/src/BloomE2E/README.md @@ -58,12 +58,11 @@ and whatever tab is showing. Most tests need nothing else. | `restart` | Stop Bloom, run an optional callback, start it again on the same collection, and return the new page. | `restart(betweenStopAndStart)` is how a test changes something Bloom only reads at startup. The -collection's languages are the case that needed it: the collection Settings dialog is a WinForms -surface CDP cannot reach, and `collectionSettings/changeLanguage` only answers while that dialog -is open, so the way to change a language is to stop Bloom, rewrite the `.bloomCollection` with -`makeCollectionXml`, and start again. Bloom is killed rather than asked to quit, so leave the -page being edited before restarting or what was typed on it is lost. Use the page `restart` -returns; the old one is closed. +collection's languages are the case that needed it, and a test does not call `restart` itself for +that: `setCollectionLanguages(bloomApp, tags)` posts the new languages to the `e2e/` hook that +writes the `.bloomCollection`, then restarts Bloom and returns the new page. Bloom is killed +rather than asked to quit, so leave the page being edited before restarting or what was typed on +it is lost. Use the page `restart` returns; the old one is closed. Teardown kills the process tree, waits for the HTTP port to go dark, and deletes the temp copy. @@ -93,7 +92,7 @@ real bug in the code under test; read the message and fix it rather than working dialog it opens: `openFormatDialog`, `clickOutsideFormatDialog`, `dragFormatDialog`, `getFormatDialogPlacement`, and the scrolling and zooming that put the gear at the edge of the screen. -- `helpers/collection.ts` — `selectBook`, `waitForCollectionReady`. +- `helpers/collection.ts` — `selectBook`, `waitForCollectionReady`, `setCollectionLanguages`. - `helpers/bookMaking.ts` — make a book, add pages, type into it, read its pages. - `helpers/addPageDialog.ts` — open, read, scroll and close the real Add Page dialog, and add a page through it. Tests that only need a page in their book call `addPage` instead. @@ -220,24 +219,22 @@ start a Vite dev server and name its port in `BLOOM_E2E_VITE_PORT`; the fixture `--vite-port` to Bloom, which loads every React control from the dev server. ```bash -# In one terminal, in src/BloomBrowserUI. Set PORT as well as --port: the port in -# vite.config.mts comes from process.env.PORT, and --port alone leaves the HMR and -# React-Refresh URLs pointing at 5173, which makes the page fail to load its entry module. -PORT=5173 pnpm exec vite --port 5173 --strictPort +# In one terminal, in src/BloomBrowserUI. Any free port works. Set PORT as well as --port: +# the port in vite.config.mts comes from process.env.PORT, and --port alone leaves the HMR +# and React-Refresh URLs pointing at 5173, which makes the page fail to load its entry +# module. +PORT=5199 pnpm exec vite --port 5199 --strictPort # In another, in src/BloomE2E -BLOOM_E2E_VITE_PORT=5173 pnpm exec playwright test +BLOOM_E2E_VITE_PORT=5199 pnpm exec playwright test ``` -**Use 5173, and set the variable.** The port is not free to choose: the page list and the toolbox -write `http://localhost:5173` into their own imports, so on any other port those two frames load -nothing and come up empty, which reads as the feature being missing rather than as a port -problem. And leaving `BLOOM_E2E_VITE_PORT` unset does not mean "no dev server": a dev build of -Bloom probes 5173 by itself, so an unset variable and a server somewhere else means the run -quietly tests the built bundle, however old it is. Both halves are in AUTOMATION-DEBT.md under -"A Vite dev server only reaches the whole UI on port 5173". - -So stop a Bloom that is already using 5173 before a run, rather than moving the dev server. +**Always set the variable.** An unset `BLOOM_E2E_VITE_PORT` does not mean "no dev server": a dev +build of Bloom probes 5173 by itself, so an unset variable and a dev server on another port means +the run quietly tests the built `output/browser`, however old it is. A stale bundle reads as the +feature being missing rather than as a stale bundle, which is what the 2026-09-02 failure looked +like. See AUTOMATION-DEBT.md under "Which front end the e2e suite tests depends on what else is +running". ## In CI diff --git a/src/BloomE2E/helpers/collection.ts b/src/BloomE2E/helpers/collection.ts index c5abfcbb1351..2380fdfa73f3 100644 --- a/src/BloomE2E/helpers/collection.ts +++ b/src/BloomE2E/helpers/collection.ts @@ -7,6 +7,7 @@ import { expect, type Page } from "@playwright/test"; import { apiGet, apiPost } from "./api"; +import type { IBloomApp } from "../fixtures/bloomTest"; /** * Wait until the editable collection is loaded and its books can be enumerated. Switching to the @@ -45,3 +46,35 @@ export async function selectBook( `&collection-id=${encodeURIComponent(collectionId)}`, ); } + +/** + * Set the collection's languages, and give Bloom back to the test with the change in effect. + * + * `tags` holds one to three language tags, for Language 1, Language 2 and Language 3. Fewer than + * three leaves the rest empty, which is how a collection ends up with no Language 3. + * + * Bloom reads a collection's languages when it opens the collection, so this restarts it, exactly + * as clicking OK in the Collection Settings dialog makes a user reopen the collection. The restart + * kills the process, so leave the page being edited first or what was typed on it is lost, and use + * the page this returns: the old one points at a dead target. + * + * The `e2e/setCollectionLanguages` hook writes the .bloomCollection with Bloom's own code, so + * everything else in the collection's settings survives. See AUTOMATION-DEBT.md for why there is + * no production API for this. + */ +export async function setCollectionLanguages( + bloomApp: IBloomApp, + tags: string[], +): Promise { + if (tags.length < 1 || tags.length > 3) + throw new Error( + `A collection has one to three languages; setCollectionLanguages was given ${tags.length}.`, + ); + await apiPost( + bloomApp.page, + "e2e/setCollectionLanguages", + JSON.stringify(tags), + "application/json", + ); + return bloomApp.restart(); +} diff --git a/src/BloomE2E/helpers/publish.ts b/src/BloomE2E/helpers/publish.ts index f66f7e73f20c..5e846947e8d6 100644 --- a/src/BloomE2E/helpers/publish.ts +++ b/src/BloomE2E/helpers/publish.ts @@ -175,24 +175,36 @@ export async function clickTextLanguage( export async function showBloomPubPreview(page: Page): Promise { await page.locator('[aria-label="refresh preview"]').click(); let player: Frame | undefined; - await expect - .poll( - async () => { - player = page - .frames() - .find((f) => f.url().includes("bloomplayer.htm")); - if (!player) return 0; - return player - .locator('[aria-label="Choose Language"]') - .count() - .catch(() => 0); - }, - { - timeout: 120000, - message: "The BloomPUB preview never showed the book.", - }, - ) - .toBeGreaterThan(0); + try { + await expect + .poll( + async () => { + player = page + .frames() + .find((f) => f.url().includes("bloomplayer.htm")); + if (!player) return 0; + return player + .locator('[aria-label="Choose Language"]') + .count() + .catch(() => 0); + }, + { timeout: 120000 }, + ) + .toBeGreaterThan(0); + } catch { + // Say which of the two steps did not happen: Bloom never gave the player a book to show, + // or the player has one and never finished loading it. Without this the failure looks the + // same either way, and the two have nothing to do with each other. + const frameUrls = page.frames().map((frame) => frame.url()); + throw new Error( + `The BloomPUB preview never showed the book. ` + + (player + ? `bloom-player is loaded at ${player.url()} but never offered the language ` + + `menu, so it never finished showing a book.` + : `No frame of this page is bloom-player, so Bloom never staged the ` + + `publication. Frames: ${frameUrls.join(", ")}.`), + ); + } return player!; } diff --git a/src/BloomE2E/tests/publish-text-languages.spec.ts b/src/BloomE2E/tests/publish-text-languages.spec.ts index 6f40964dfe84..8a4f458580f7 100644 --- a/src/BloomE2E/tests/publish-text-languages.spec.ts +++ b/src/BloomE2E/tests/publish-text-languages.spec.ts @@ -22,8 +22,7 @@ import { setContentLanguages, typeInGroup, } from "../helpers/bookMaking"; -import { selectBook } from "../helpers/collection"; -import { restartWithCollectionSettings } from "../helpers/collectionSettings"; +import { selectBook, setCollectionLanguages } from "../helpers/collection"; import { clickTextLanguage, expectTextLanguageRows, @@ -95,12 +94,9 @@ test.describe("the Text Languages publish list", () => { ); await goToPage(page, coverBeforeRestart!.id); - // Now swap German out for Spanish. Collection settings have no API and their dialog is a - // WinForms surface CDP cannot reach, so the way to change them is to quit Bloom, rewrite - // the .bloomCollection, and start again. See AUTOMATION-DEBT.md. - const newPage = await restartWithCollectionSettings(bloomApp, { - languages: FINAL_LANGUAGES, - }); + // Now swap German out for Spanish. Bloom reads the collection's languages when it opens + // the collection, so this restarts it; see setCollectionLanguages. + const newPage = await setCollectionLanguages(bloomApp, FINAL_LANGUAGES); bookFolder = await findBookFolder(newPage, BOOK_TITLE); await selectBook(newPage, bookFolder); await switchTab(newPage, "edit"); @@ -347,36 +343,34 @@ test.describe("the Text Languages publish list", () => { await setContentLanguages(page, ["en"]); }); - // Skipped until BL-16806 lands: https://issues.bloomlibrary.org/youtrack/issue/BL-16806 - // - // It fails on CI every time, on the language NAME. The difference it catches is real -- it is - // what Bloom shows a user -- but the fix has to settle WHICH name a dropped language gets, and - // until that is decided this test would keep the nightly red over a known cause. Re-enable it - // with the fix, and expect whatever name BL-16806 settles on. - // - // Expected: español Received: espagnol + // This test no longer meets the difference BL-16806 is about, and master's version of it + // does, so do not copy master's comment back here. Master drops the language by rewriting the + // .bloomCollection, and Bloom then asks LibPalaso for the name of the dropped language "in" + // the collection's metadata language. LibPalaso honors that only where a native ICU library + // is findable, and Bloom ships icu.net but no icuuc.dll, so the CI runner answers "espagnol", + // French for Spanish, every time, and a developer machine answers "español" every time. It is + // a real difference in what Bloom shows a user, tracked on BL-16806. // - // "espagnol" is French for Spanish, and the answer depends on the machine, not on the run. - // Bloom asks LibPalaso for the name of the dropped language "in" the collection's metadata - // language, which is French here; LibPalaso honors that request only where a native ICU - // library is findable, and Bloom ships icu.net but no icuuc.dll. So the CI runner gives - // "espagnol" (nightly runs 33665790357 and 33685669405) while a developer machine ignores the - // request and gives the autonym "español" (checked in the real Publish tab). Everything else - // about the row -- unchecked, not incomplete, enabled -- is right. - // - // A local failure of this test is usually something else: it has other steps that time out on - // a loaded machine, and dies before reaching this assertion. - test.skip("keeps a language that the collection no longer has, under its own name [Test Case ID 169]", async ({ + // This version drops the language through e2e/setCollectionLanguages, the code the Collection + // Settings dialog's OK button runs, which keeps the language's collection name. So the list + // reads "Spanish" on every machine, and the lookup that differs is never reached. The + // coverage that costs is recorded in src/BloomE2E/AUTOMATION-DEBT.md. + test("keeps a language that the collection no longer has, under the name the collection remembers [Test Case ID 169]", async ({ bloomApp, }) => { test.setTimeout(180000); // Drop Spanish from the collection. The book still has Spanish text, so the language stays - // in the list; but the collection no longer supplies a name for it, so Bloom falls back to - // the name the language calls itself. - const withoutSpanish = await restartWithCollectionSettings(bloomApp, { - languages: ["en", "fr"], - }); + // in the list, named "Spanish". Removing a language does not throw its settings away: + // CollectionSettingsDialog.UpdateLanguageSettings moves the displaced language to the end + // of the collection's list, name and font and all, so the name outlives the removal. (This + // test used to expect "español", the language's own name for itself, because it dropped + // the language by rewriting the .bloomCollection, which no part of Bloom does. Going + // through the same code the dialog's OK button runs is what changed the answer.) + const withoutSpanish = await setCollectionLanguages(bloomApp, [ + "en", + "fr", + ]); await selectBook(withoutSpanish, bookFolder); await openPublishDestination(withoutSpanish, "Web"); // In any order: where a language the collection no longer names sits in the list is not @@ -397,19 +391,20 @@ test.describe("the Text Languages publish list", () => { disabled: false, }, { - name: "español", + name: "Spanish", incomplete: false, checked: false, disabled: false, }, ], - "Spanish did not stay in the list, unchecked and under its own name.", + "Spanish did not stay in the list, unchecked and under the name the collection remembers.", ); // Put Spanish back, for the test that follows. - const withSpanish = await restartWithCollectionSettings(bloomApp, { - languages: FINAL_LANGUAGES, - }); + const withSpanish = await setCollectionLanguages( + bloomApp, + FINAL_LANGUAGES, + ); await selectBook(withSpanish, bookFolder); }); diff --git a/src/BloomExe/web/controllers/E2eTestingApi.cs b/src/BloomExe/web/controllers/E2eTestingApi.cs index aad75c0a2dcb..1f4bdb30ad75 100644 --- a/src/BloomExe/web/controllers/E2eTestingApi.cs +++ b/src/BloomExe/web/controllers/E2eTestingApi.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Bloom.Api; using Bloom.Book; @@ -123,6 +124,21 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) false // does not need the UI thread ); + // POST body is a JSON array of one to three language tags, e.g. ["en","fr","es"], for + // Language1, Language2 and Language3. Sets the collection's languages and writes the + // .bloomCollection file, which a test otherwise has to compose by hand: the Collection + // Settings dialog is a WinForms surface CDP cannot reach, and its own + // collectionSettings/changeLanguage endpoint only answers while that dialog is open. + // Changing a collection's languages still needs the collection to be reopened, exactly + // as it does for a user who clicks OK in that dialog, so the caller must restart Bloom + // (src/BloomE2E/helpers/collection.ts setCollectionLanguages does both). + // Must run on the UI thread because it changes the settings the UI is showing. + apiHandler.RegisterEndpointHandler( + kApiUrlPart + "setCollectionLanguages", + HandleSetCollectionLanguages, + true + ); + // GET returns the pages the Add Page dialog would offer for the selected book: the // path of its template book, and the id and label of each template page. A test needs // these to call the production "addPage" endpoint, and the dialog itself reads them @@ -312,6 +328,76 @@ private void HandleGetTemplatePages(ApiRequest request) request.ReplyWithJson(pages); } + /// + /// Set the collection's Language1, Language2 and Language3 to the tags in the POST body (a + /// JSON array of one to three tags), and save the .bloomCollection file. Fewer than three + /// tags leaves the languages that were not named empty, except that a collection naming + /// only one language repeats it as Language2, which is what Bloom's own new-collection code + /// writes. + /// + /// This does the same work as clicking OK in the Collection Settings dialog, including + /// keeping a language that is no longer one of the first three in the collection's list of + /// languages. Like that dialog, it needs the collection reopened before the change is + /// everywhere it should be; the caller restarts Bloom. + /// + private void HandleSetCollectionLanguages(ApiRequest request) + { + var tags = request.RequiredPostObject(); + if (tags == null || tags.Length < 1 || tags.Length > 3) + throw new ArgumentException( + "e2e/setCollectionLanguages takes a JSON array of one to three language tags." + ); + if (string.IsNullOrWhiteSpace(tags[0])) + throw new ArgumentException( + "e2e/setCollectionLanguages needs a tag for Language1." + ); + + // Start from the collection's own writing systems, so that everything about each + // language except its tag (the font, the line height, the writing direction) keeps the + // value it had, then put the requested tag on each one. + // + // Each language is named in English ("French", not "français"), which is what a person + // gets by keeping the English name Bloom's language chooser offers. Bloom calls a name + // that is not the language's own name for itself a custom name, and a custom name is + // the one thing it shows verbatim everywhere; leaving the name uncustomized would make + // each screen name the language in whatever language it liked. + var pending = new WritingSystem[3]; + for (var i = 0; i < 3; i++) + { + pending[i] = _collectionSettings.AllLanguages[i].Clone(); + var tag = i < tags.Length ? tags[i].Trim() : string.Empty; + pending[i].ChangeTag(tag); + if (!string.IsNullOrEmpty(tag)) + { + // ChangeTag has already replaced the name with the one the language uses for + // itself, but it leaves IsCustomName alone. So clear that flag before asking + // for the English name: a language that arrived here with a custom name would + // otherwise be told "you have a name a person chose", and hand back the name + // ChangeTag just computed instead of the English one. + pending[i].SetName(pending[i].Name, false); + pending[i].SetName(pending[i].GetNameInLanguage("en"), true); + } + } + if (string.IsNullOrEmpty(pending[1].Tag)) + { + pending[1].ChangeTag(pending[0].Tag); + // Same reason as the loop above: ChangeTag leaves IsCustomName alone, so a + // Language2 that arrived here with a custom name would keep the name of the + // language it just replaced. + pending[1].SetName(pending[1].Name, false); + pending[1].SetName(pending[1].GetNameInLanguage("en"), true); + } + + CollectionSettingsDialog.UpdateLanguageSettings( + _collectionSettings.AllLanguages, + pending, + pending.Select(language => language.FontName).ToArray() + ); + _collectionSettings.Save(); + + request.PostSucceeded(); + } + /// /// Stage the currently selected book as a BloomPUB and reply with the localhost URL of the /// staged .htm file, which a test can load in bloom-player.