Skip to content
Draft
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
2 changes: 1 addition & 1 deletion build/testing-inputs.pin
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
# Format: one `key=value` per line. Blank lines and lines starting with # are ignored.

repo=https://github.com/BloomBooks/bloom-testing-inputs.git
commit=cd0df0a7310312ff1e014716d89c39e00ef0615d
commit=aa2e7c2d31bdef05eb6211bb4ef076135ccbbb11
39 changes: 39 additions & 0 deletions src/BloomE2E/AUTOMATION-DEBT.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ only stable marker available. Fix direction: `data-testid="workspace-tab-collect
(etc.) on each tab and one on the shell root, and drop the label matching.
(Found 2026-09-01 while scaffolding src/BloomE2E.)

seen again 2026-09-01, in the Edit tab's page thumbnail menu: the items
`pageThumbnailList.tsx` renders carry no id, class or `data-testid` (all their styling is
inline), so `src/BloomE2E/helpers/pageThumbnails.ts` has to find "Copy Page" and "Paste Page"
by their English labels, exactly as the top bar does. Same fix: a `data-testid` per command,
taken from the `commandId` the menu already has.

## The component-tester Playwright suites are not in CI

`nightly.yml` runs vitest, C#, and visual-regression only; nothing runs
Expand Down Expand Up @@ -171,3 +177,36 @@ test can currently clear a box by any faster route. Fix direction: understand wh
CKEditor does with a programmatic value change; a supported "set the text of this box"
path would let long text be set at once.
(Found 2026-09-01 automating Test Case ID 169.)

## The page menu offers commands that silently do nothing while a page is loading

Copy Page and Paste Page go through `EditingModel.SaveThen`, which quietly gives up when the
editing state machine is not in Editing or NoPage (`EditingStateMachine.ToSavePending` returns
false and `CopyPage` passes `() => { }` as its "wrong state" action). The menu does not know
this: `PageThumbnailList.IsContextMenuCommandEnabled` disables commands during SavePending, but
NOT during Navigating, so while a page is still loading both commands look available and both
do nothing at all, with no error and no message. Copy Page itself then saves and reloads the
page, which reopens the same window for the very next click.

Cost, twice over. For a person: click Copy Page and then Paste Page quickly and the paste is
lost with no feedback. For a test: `src/BloomE2E/helpers/pageThumbnails.ts` has to carry
`markEditablePage` / `waitForEditablePageReload`, which stamp the page's document and wait for
Bloom to replace it, purely to know when the model has come back to Editing — the page url
cannot answer it, because Bloom reloads a page to the same in-memory url. Fix direction: make
the enabled test cover the Navigating state too, so a command that cannot run is greyed out;
or, better, queue the command instead of dropping it. Either would let the helper drop the
document-marking dance.
(Found 2026-09-01 while automating Test Case ID 348, copy page preserves everything.)

## Copying a page between two Bloom instances cannot be tested at all

The manual case "Copy Page Preserves Everything" (Test Case ID 348) ends by copying a page from
one running Bloom into a second one. Bloom's page clipboard is a pair of fields on the one
`EditingModel` instance (`_pageDivFromCopyPage`, `_bookPathFromCopyPage`), not the Windows
clipboard, so nothing crosses a process boundary; the feature is known not to work in 6.5. The
e2e fixture is also built around one Bloom per worker, so a test could not stage it today even
if the feature worked. The automated test therefore covers the within-book and between-books
cases only, and the Notion case stays Partial. Fix direction: decide whether cross-instance
copy is a feature we want; if it is, put the page on the real clipboard, and give the launch
fixture a way to run a second instance.
(Found 2026-09-01 while automating Test Case ID 348.)
155 changes: 155 additions & 0 deletions src/BloomE2E/helpers/bookHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Read a book's saved .htm from disk and describe what each of its pages contains.
//
// Bloom writes the book to disk as it edits, so the file is the product's own record of what a
// page holds — a better subject for "did the copy preserve everything?" than the editing DOM,
// which shows only the one page on screen and decorates it with editing-only markup.
//
// Parsing happens INSIDE Bloom's own page, with DOMParser, rather than in Node: this package
// has no HTML parser among its dependencies, and adding one to read a file we already have is
// not worth it. Nothing is written back; the page is only borrowed as a parser.

import * as fs from "node:fs";
import * as Path from "node:path";
import { expect, type Page } from "@playwright/test";

/** What one page of a book holds, reduced to the things the copy-page test measures. */
export interface IPageContents {
/** The page div's own id. Bloom gives a pasted page a fresh one. */
id: string;
/** The template the page came from, e.g. the Custom layout's id. */
lineage: string;
/** Every user-defined style class (`Foo-style`) on the page's editable text. */
styleClasses: string[];
/** The `src` of every image on the page, relative to the book folder. */
imageSources: string[];
/** The id of every Talking Book recorded span; each names a file in `audio/`. */
audioSentenceIds: string[];
/** The `src` of every video source on the page, `#t=` trim fragment included. */
videoSources: string[];
/**
* The page's origami layout, as one string per split: the orientation and the two
* component sizes. Comparing these says whether a custom layout survived the copy.
*/
layout: string[];
}

/** A book as read from disk: its pages, plus the user-defined styles its head carries. */
export interface IBookContents {
pages: IPageContents[];
/** The text of the book's `userModifiedStyles` block, where Bloom keeps custom styles. */
userModifiedStyles: string;
}

/** The path of a book folder's own .htm file, which Bloom names after the folder. */
export function bookHtmlPath(bookFolder: string): string {
return Path.join(bookFolder, `${Path.basename(bookFolder)}.htm`);
}

/**
* Read the book at `bookFolder` and describe its numbered (non-front/back-matter) pages, in
* order. `page` is used only as a DOM parser.
*/
export async function readBook(
page: Page,
bookFolder: string,
): Promise<IBookContents> {
const html = fs.readFileSync(bookHtmlPath(bookFolder), "utf8");
return page.evaluate((source) => {
const document = new DOMParser().parseFromString(source, "text/html");
const styleElement = document.querySelector(
'style[title="userModifiedStyles"]',
);
const pages = [
...document.querySelectorAll("div.bloom-page.numberedPage"),
].map((pageDiv) => ({
id: pageDiv.id,
lineage: pageDiv.getAttribute("data-pagelineage") ?? "",
styleClasses: [
...new Set(
[...pageDiv.querySelectorAll(".bloom-editable")].flatMap(
(editable) =>
[...editable.classList].filter((c) =>
c.endsWith("-style"),
),
),
),
].sort(),
imageSources: [...pageDiv.querySelectorAll("img")].map(
(img) => img.getAttribute("src") ?? "",
),
audioSentenceIds: [
...pageDiv.querySelectorAll(".audio-sentence"),
].map((span) => span.id),
videoSources: [...pageDiv.querySelectorAll("video source")].map(
(source) => source.getAttribute("src") ?? "",
),
layout: [...pageDiv.querySelectorAll(".split-pane")].map(
(split) => {
const orientation = split.classList.contains(
"horizontal-percent",
)
? "horizontal"
: "vertical";
// The inline style is where origami records the split percentage.
const sizeOf = (position: string) =>
split
.querySelector(
`:scope > .split-pane-component.position-${position}`,
)
?.getAttribute("style") ?? "";
const [first, second] =
orientation === "horizontal"
? ["top", "bottom"]
: ["left", "right"];
return `${orientation} ${sizeOf(first)} | ${sizeOf(second)}`;
},
),
}));
return {
pages,
userModifiedStyles: styleElement?.textContent ?? "",
};
}, html);
}

/**
* Wait until the book on disk has `count` numbered pages, then return it. Bloom saves after the
* edit, not with it, so a test that reads the file the moment a click returns can read the old
* one. This polls the file rather than sleeping.
*/
export async function waitForBookWithPageCount(
page: Page,
bookFolder: string,
count: number,
timeoutMs = 60000,
): Promise<IBookContents> {
// Return the very read that satisfied the check. A second read after the poll could catch
// Bloom mid-write and hand back a different, half-written file.
let book: IBookContents | undefined;
await expect
.poll(
async () => {
book = await readBook(page, bookFolder);
return book.pages.length;
},
{
timeout: timeoutMs,
message:
`${bookHtmlPath(bookFolder)} never came to have ${count} numbered pages. ` +
`Bloom may not have saved the change.`,
},
)
.toBe(count);
return book!;
}

/** True if `relativePath` (as a page's markup names it) exists inside the book folder. */
export function bookFileExists(
bookFolder: string,
relativePath: string,
): boolean {
// A video source carries a trim fragment, e.g. "video/x.mp4#t=0.0,2.0"; the file is the
// part before it.
const file = relativePath.split("#")[0];
return fs.existsSync(Path.join(bookFolder, file));
}
Loading