diff --git a/.claude/skills/playwright-cli/SKILL.md b/.claude/skills/playwright-cli/SKILL.md index fe30992ad9..8bfa4bafd8 100644 --- a/.claude/skills/playwright-cli/SKILL.md +++ b/.claude/skills/playwright-cli/SKILL.md @@ -6,6 +6,10 @@ allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*) # Browser Automation with playwright-cli +## BlockNote test conventions + +When authoring or changing tests in this repository, follow [testing-skill](../testing-skill/SKILL.md). Colocated component/accessibility tests use Vitest browser mode, one `*.test.ts` file with separate describe groups, and the shared Docker e2e runner. Read its [accessibility reference](../testing-skill/references/accessibility-tests.md) for assertion scopes, navigation helpers, and native-only screen-reader tests. These repository conventions take precedence over the generic Playwright test-generation examples below and in the references: do not introduce a standalone Playwright runner, one `.spec.ts` per scenario, or native browser-suite runs. + ## Quick start ```bash diff --git a/.claude/skills/playwright-cli/references/spec-driven-testing.md b/.claude/skills/playwright-cli/references/spec-driven-testing.md index 70f05135eb..f07d45f90f 100644 --- a/.claude/skills/playwright-cli/references/spec-driven-testing.md +++ b/.claude/skills/playwright-cli/references/spec-driven-testing.md @@ -1,5 +1,7 @@ # Spec-driven testing (plan → generate → heal) +For BlockNote repository tests, use [testing-skill](../../testing-skill/SKILL.md) and its [accessibility conventions](../../testing-skill/references/accessibility-tests.md). The generic runner setup and one-test-per-file instructions here do not apply to BlockNote's Vitest browser suite. + End-to-end workflow for authoring and maintaining Playwright tests using `playwright-cli`. The three sections below can be used independently: - **Planning** — explore the app, produce a spec file describing what to test. diff --git a/.claude/skills/testing-skill/SKILL.md b/.claude/skills/testing-skill/SKILL.md index 7efa847d7f..0607ae97d9 100644 --- a/.claude/skills/testing-skill/SKILL.md +++ b/.claude/skills/testing-skill/SKILL.md @@ -1,6 +1,6 @@ --- name: testing-skill -description: Instructions for writing, running, and updating unit/end-to-end tests. Should be used when prompted specifically to add tests for a given feature, bug, or regression. +description: Instructions for writing, running, and updating unit, end-to-end, and colocated accessibility tests. Use when adding or changing tests for a feature, bug, or regression. --- # Testing @@ -52,6 +52,12 @@ The device-only behaviors above, checked on real hardware before a release: `tests/src/end-to-end`: Tests that need a real browser and span multiple packages go here — chiefly tests which interact with the editor UI or simulate user interaction, but also browser integration tests that exercise complete flows without interaction (e.g. exporting a full document, static rendering). New subdirectories can be added if the functionality being tested is not covered by any of the existing ones. Important note about existing E2E tests - many are written poorly and should only loosely be used as reference. We want to avoid abstraction layers and `waitForTimeout` as much as possible. +### Colocated Component and Accessibility Tests + +Component accessibility coverage is colocated with the component/block, but runs in the existing Docker e2e suite. Use the same `*.browser.test.ts` file as for browser unit tests. Use separate `test.describe("keyboard")`, `"ARIA"`, `"axe"`, and, where needed, `"visual"` groups, as well as an additional `"behaviour"` group for existing unit tests. Native screen-reader edge cases belong in an optional `*.screen-reader.test.ts` file and run locally only, outside regular e2e/CI. + +When adding or changing this coverage, read [Accessibility test structure and helpers](references/accessibility-tests.md). It defines each group's assertions, fixture/helper conventions, discovery configuration, and run commands. Use the colocated FormattingToolbar, BlockNoteView, and Heading tests as the concrete examples; do not create per-component `a11y/` directories or separate files for each assertion type. + ## When & How to Add Tests Placement and proof obligations are governed by the ladder above: pick the rung red-first, and verify every new test fails without the code change it covers (for edge cases and new features too — existing tests continuing to pass is exactly the situation that demands new red-first ones). @@ -76,6 +82,8 @@ bash tests/docker-run.sh -e CI=1 -- --run [filters] **Never run the browser suite natively** (`vp test -c vite.config.browser.ts` outside the container) — not even "just one file". Two failure modes, both silent: the screenshot matcher _seeds_ a new `-darwin`/`-win32` baseline for every screenshot test that has none (passing without comparing anything, and littering the tree with hundreds of unvetted PNGs — these are gitignored as a backstop, and only `-linux` baselines are tracked), and several suites genuinely behave differently outside Linux (e.g. caret placement is font-metric-dependent, so platform-shared JSON snapshots mismatch). If Docker isn't running, notify the user to launch it instead of falling back to a native run. +Native screen-reader tests use the separate local runner described in the accessibility reference; the Docker-only rule above applies to the regular browser suite. + A specific test file may be targeted by appending (part of) its name as a filter. A single browser may be targeted with `--project "e2e (chromium)"`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass). Screenshot baselines can be regenerated with `--update=true` (as `e2e:updateSnaps` does). Always attach the value: vitest declares the flag as `-u, --update [type]` (it also accepts `new`/`all`/`none`), so a bare `-u` before a filter swallows the filter as its value and silently runs the **whole** suite in update mode. `--update=true` cannot, so filters stay filters in any position. Note that `-u` only rewrites baselines whose comparison **fails** — a small intended change (e.g. a short text edit) that fits inside the suite's 2% pixel tolerance leaves the baseline stale while the test passes. To force a fresh capture, delete the baseline file first. Baselines are per-browser (`--linux.png`); after regenerating, always inspect the images before committing them. diff --git a/.claude/skills/testing-skill/references/accessibility-tests.md b/.claude/skills/testing-skill/references/accessibility-tests.md new file mode 100644 index 0000000000..cae375be8e --- /dev/null +++ b/.claude/skills/testing-skill/references/accessibility-tests.md @@ -0,0 +1,79 @@ +# Colocated component and accessibility tests + +All paths below are relative to the repository root. These are browser integration tests, identified by the `.browser.test.ts` or `.browser.test.tsx` suffix. They use the existing e2e configuration and CI jobs, not a separate accessibility browser project or workflow. + +## Files and fixtures + +Place files directly beside the component or block: + +```text +FormattingToolbar.tsx +FormattingToolbar.browser.test.tsx +FormattingToolbar.screen-reader.test.ts # optional, native/local only +__snapshots__/ # ARIA snapshots +__screenshots__/ # browser-specific visual baselines +``` + +Examples to read before extending an area: + +- `packages/react/src/components/FormattingToolbar/FormattingToolbar.browser.test.tsx` +- `packages/react/src/editor/BlockNoteView.browser.test.tsx` +- `packages/core/src/blocks/Heading/block.browser.test.tsx` + +Keep the browser scenarios in one file, grouped by assertion purpose. Do not split them into `.keyboard.test.ts`, `.aria.test.ts`, `.axe.test.ts`, or `.visual.test.ts` files or put them in a component-local `a11y/` directory. Only add groups relevant to the component. + +Declare document fixtures in their consuming test files, with names ending in `Document` and typed as `PartialBlock[]`. Prefer plain data; avoid fixture-selection factories and separate `fixtures.ts` files. If browser and screen-reader tests share a fixture or helper, export it from the owning test file and import it in the other. Guard browser suite registration with `if (!screenReader)` when the native suite imports that file, so importing helpers does not collect browser scenarios in the native run. + +Write explicit named tests for the distinct user interactions covered by the component. Name each test after the outcome it asserts, rather than only the actions it performs. Share repeated test logic through named functions near the start of the file or describe block, rather than registering different interactions in a loop. Use names specific to the component and element being tested, such as `toggleButton`, `linkInput`, or `toolbarMenu`, rather than `control`. + +## Describe blocks and assertion scope + +The same keyboard actions may appear in several groups. Each group checks a different outcome; avoid adding every kind of assertion to every test. + +| Group | Actions and assertions | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `behavior` | Navigate using the keyboard and activate with Enter. Assert the component's functional effect. For document edits, subscribe with `editor.onChange(vi.fn())` after navigation and before activation, register the unsubscribe with `onTestFinished`, and assert the callback ran. View-only changes may need another observable effect: toggle-heading tests check persisted expansion state. Keep detailed editor-command unit tests colocated with those commands rather than duplicating them here. | +| `keyboard` | Assert the relevant focus transitions explicitly with `toHaveFocus()`: entering the editor, reaching the toolbar/menu/item, and returning to the editor or leaving it. Assert visibility and, for document traversal, selection/block/table-cell changes through the selection command. Check the visual focus indicator as well; DOM focus alone does not prove that focus is visible. | +| `ARIA` | Follow the keyboard interaction and compare named ARIA snapshots before and after activation to check names, roles, pressed/expanded state, etc. Scope item tests to the item: adding another toolbar button must not invalidate Bold's snapshots. Use a separate whole-toolbar tree snapshot to cover membership and overall structure. If activation changes the item's accessible name, locate its resulting name explicitly. | +| `axe` | Open the relevant UI state and run the axe command. Scan a toolbar, menu, or popover independently where appropriate; use a whole-document scan for broad schema coverage. Attach the returned manual-review warnings to the test. Axe scans do not replace keyboard navigation or focus-indicator checks. | +| `visual` | Cover visual accessibility scenarios such as text-spacing overrides and 200% text resizing. Use `screenshotFull` for tall documents, wait for fonts/media, and mask nondeterministic video frames where needed. These checks concern layout/readability rather than individual button activation and should only ever be used for specific requirements defined in the WCAG 2.0-2.2 guidelines. | + +## Test setup and commands + +Define a local `TestEditor` component using `useCreateBlockNote` and `BlockNoteView`. Use `.tsx` and JSX when rendering React components. Add a `renderTestEditor` function when the suite needs repeated rendering or readiness steps. Keep document data, initial focus, and setup specific to the scenario; do not introduce a shared editor fixture for unrelated suites. + +Consume browser operations through the typed `commands` export in `tests/src/utils/context.ts`. The implementations live in `tests/src/utils/accessibility.ts` and `tests/src/utils/keyboard.ts`; their JSDoc describes inputs, results, synchronization, and usage. Register new reusable browser operations in the relevant Vite configuration and add their client signatures to `context.ts`. Do not import command implementations into tests, add per-caller type casts, or wrap a command just to forward its arguments. + +Keep scenario-specific sequences, such as navigating to a toolbar item, in the consuming test file. Share them only where another test actually needs the same sequence. Keep action sequences separate from behavior assertions; keyboard tests should explicitly assert the focus and selection transitions they cover. + +The default browser setup already disables motion and tracks ProseMirror focus reconciliation. Do not duplicate those mechanisms in fixtures. Wait for observable readiness with retrying assertions instead of arbitrary sleeps. For example, an opened menu can be visible before its initial item receives focus: assert that focus before sending navigation keys. + +Use accessible locators (`getByRole` with a name or `getByLabel`) and pass their selectors to commands. Prefer live locators for asynchronously mounted elements. When tabbing or arrowing to an item, stop as soon as the target is reached and enforce an explicit maximum number of keypresses. Choose a generous bound for the fixture and fail immediately with the target and limit in the message if it is exhausted; do not rely on the test timeout. The bound is a safety limit, not a fixed navigation sequence or a duplicate list of controls. + +## Native screen-reader edge cases + +Use a separate `*.screen-reader.test.ts` only for actual reader announcements/navigation that ARIA snapshots cannot establish. Reuse keyboard navigation helpers, render the suite’s editor and call `commands.startReader(locator.selector)`, and assert the transcript and stop the reader in `afterEach`. Avoid duplicating every browser scenario. Transcripts are reader-specific; review changes rather than accepting them blindly. + +These tests run locally with VoiceOver on macOS or NVDA on Windows and require the installed reader and GuidePup permissions. They must stay excluded from regular e2e, node tests, and CI. `tests/screen-reader/vite.config.screen-reader.ts` is for this native runner only. + +## Discovery and commands + +Browser files use `*.browser.test.ts` (or `.tsx`); native screen-reader tests use `*.screen-reader.test.ts`; node tests use `*.test.ts`. Existing glob patterns discover browser tests and exclude them from node runs without per-file configuration. Accessibility checks are part of the regular suite. Production files must not import test files; lint enforces this boundary. + +Browser and screen-reader files also have lint rules for focused tests, malformed assertions, unhandled promises, direct command-implementation imports, test-ID locators, and explicit sleep APIs (`waitForTimeout`, `.sleep`, and Node timer imports). Readiness waits should use retrying assertions. + +Run from the repository root: + +```bash +# Existing e2e suite, always Docker; narrow by file, group, and/or browser. +vp run e2e FormattingToolbar.browser.test.tsx +vp run e2e FormattingToolbar.browser.test.tsx -t keyboard --project "e2e (webkit)" --retry=0 + +# Update only the selected suite, then review and rerun without updating. +vp run e2e:updateSnaps FormattingToolbar.browser.test.tsx + +# Native/local only, never part of regular e2e or CI. +vp run test:screen-reader FormattingToolbar.screen-reader.test.ts +``` + +Evaluate known functionality gaps individually; any intentional skip needs a comment explaining the specific failure. Do not skip timing failures or increase retries to hide them: fix readiness/locator handling and verify repeated runs with retries disabled. Do not refresh snapshots to accept an unexplained regression. diff --git a/packages/core/src/blocks/Heading/__snapshots__/block.browser.test.tsx.snap b/packages/core/src/blocks/Heading/__snapshots__/block.browser.test.tsx.snap new file mode 100644 index 0000000000..9a2a845526 --- /dev/null +++ b/packages/core/src/blocks/Heading/__snapshots__/block.browser.test.tsx.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ARIA > read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed 1`] = ` +"- textbox: + - button "Expand or collapse" + - heading "Details" [level=2]" +`; + +exports[`ARIA > read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed again 1`] = ` +"- textbox: + - button "Expand or collapse" + - heading "Details" [level=2]" +`; + +exports[`ARIA > read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > expanded 1`] = ` +"- textbox: + - button "Expand or collapse" [expanded] + - heading "Details" [level=2] + - paragraph: Nested read-only content" +`; diff --git a/packages/core/src/blocks/Heading/block.browser.test.tsx b/packages/core/src/blocks/Heading/block.browser.test.tsx new file mode 100644 index 0000000000..f50025bcc5 --- /dev/null +++ b/packages/core/src/blocks/Heading/block.browser.test.tsx @@ -0,0 +1,166 @@ +import { commands } from "@root/tests/src/utils/context.js"; +import { type PartialBlock } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { render } from "vitest-browser-react"; + +import { expect, test, vi, beforeEach, afterEach } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +// Start from "Before editor" after setup. Keep these navigation steps +// identical to focusToggleHeadingWithAssertions in the keyboard suite. +async function focusToggleHeading() { + const toggleButton = page.getByRole("button", { + name: "Expand or collapse", + exact: true, + }); + for ( + let presses = 0; + document.activeElement !== toggleButton.element(); + presses++ + ) { + if (presses >= 10) { + throw new Error( + "Could not focus the toggle heading button after 10 Tab presses", + ); + } + await commands.pressKey("Tab"); + } + + return toggleButton; +} + +const toggleHeadingDocument: PartialBlock[] = [ + { + id: "read-only-toggle-heading", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Details", + children: [{ type: "paragraph", content: "Nested read-only content" }], + }, +]; + +function TestEditor() { + const editor = useCreateBlockNote({ initialContent: toggleHeadingDocument }); + return ( + <> + + + + + ); +} + +beforeEach(async () => { + localStorage.removeItem("toggle-read-only-toggle-heading"); + await render(); + await expect.element(page.getByRole("textbox")).toBeVisible(); + await document.fonts.ready; + page + .getByRole("button", { name: "Before editor", exact: true }) + .element() + .focus(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.removeItem("toggle-read-only-toggle-heading"); +}); + +test.describe("behavior", () => { + test("read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it", async () => { + const setItem = vi.spyOn(Storage.prototype, "setItem"); + await focusToggleHeading(); + await commands.pressKey("Enter"); + expect(setItem).toHaveBeenCalledExactlyOnceWith( + "toggle-read-only-toggle-heading", + "true", + ); + setItem.mockClear(); + await commands.pressKey("Enter"); + expect(setItem).toHaveBeenCalledExactlyOnceWith( + "toggle-read-only-toggle-heading", + "false", + ); + }); +}); + +test.describe("keyboard", () => { + // Same navigation as focusToggleHeading, with focus and visibility assertions. + async function focusToggleHeadingWithAssertions() { + const toggleButton = page.getByRole("button", { + name: "Expand or collapse", + exact: true, + }); + await expect.element(toggleButton).toBeVisible(); + const unfocusedOutline = await commands.readOutline(toggleButton.selector); + + for ( + let presses = 0; + document.activeElement !== toggleButton.element(); + presses++ + ) { + if (presses >= 10) { + throw new Error( + "Could not focus the toggle heading button after 10 Tab presses", + ); + } + await commands.pressKey("Tab"); + } + await expect.element(toggleButton).toHaveFocus(); + await commands.checkFocusOutline(toggleButton.selector, unfocusedOutline); + + return { toggleButton, unfocusedOutline }; + } + // Toggle headings represent interactive blocks in this accessibility vertical slice. + // Keyboard access to toggle controls from within an editable block is not implemented. + test.skip("editable toggle heading: toggle is keyboard accessible while the editor has focus", async () => { + // TODO: The keyboard interaction for controls within an editable editor is TBD. + // Reach the toggle while the editor has focus, expand/collapse its children, + // and return to editing without changing the document content. + // In editable mode, skip the toggle in page tab navigation outside the editor; + // in read-only mode it must remain a regular tab stop (tested below). + // Capture its focus indicator and announcements of its name, role and expanded state. + // A direct toggle shortcut alone would not require a full browser test; + // this placeholder covers the eventual keyboard focus/navigation behavior. + }); + + test("read-only toggle heading: Tab navigation and visible focus after Enter", async () => { + const { toggleButton, unfocusedOutline } = + await focusToggleHeadingWithAssertions(); + await commands.pressKey("Enter"); + await expect.element(toggleButton).toHaveFocus(); + await commands.checkFocusOutline(toggleButton.selector, unfocusedOutline); + await commands.pressKey("Enter"); + await expect.element(toggleButton).toHaveFocus(); + await commands.checkFocusOutline(toggleButton.selector, unfocusedOutline); + + await commands.pressKey("Tab"); + await expect + .element(page.getByRole("button", { name: "After editor", exact: true })) + .toHaveFocus(); + await commands.pressKey("Shift+Tab"); + await expect.element(toggleButton).toHaveFocus(); + await commands.checkFocusOutline(toggleButton.selector, unfocusedOutline); + }); +}); + +test.describe("ARIA", () => { + test("read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it", async () => { + await focusToggleHeading(); + const editor = page.getByRole("textbox"); + expect(await commands.readAriaSnapshot(editor.selector)).toMatchSnapshot( + "collapsed", + ); + await commands.pressKey("Enter"); + expect(await commands.readAriaSnapshot(editor.selector)).toMatchSnapshot( + "expanded", + ); + await commands.pressKey("Enter"); + expect(await commands.readAriaSnapshot(editor.selector)).toMatchSnapshot( + "collapsed again", + ); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index a7f9fb44ba..94cab86430 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -22,5 +22,6 @@ "skipLibCheck": true, "noErrorTruncation": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.browser.test.*", "src/**/*.screen-reader.test.ts"] } diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 3610ce8929..78616f4ca4 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -30,7 +30,11 @@ export default defineConfig({ setupFiles: ["./vitestSetup.ts"], // `.browser.test` files need a real browser; the tests package's browser // suite runs them. - exclude: [...configDefaults.exclude, "**/*.browser.test.*"], + exclude: [ + ...configDefaults.exclude, + "**/*.browser.test.*", + "**/*.screen-reader.test.ts", + ], }, plugins: [webpackStats()], build: { diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.browser.test.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbar.browser.test.tsx new file mode 100644 index 0000000000..87f03a22ac --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.browser.test.tsx @@ -0,0 +1,565 @@ +import { commands } from "@root/tests/src/utils/context.js"; +import { selectToLineEndKey } from "@root/tests/src/utils/context.js"; +import { type PartialBlock } from "@blocknote/core"; +import { expect, test, vi } from "vite-plus/test"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { useEffect } from "react"; +import { render } from "vitest-browser-react"; +import type { BlockNoteEditor } from "@blocknote/core"; +import { page, server } from "vite-plus/test/browser"; + +function TestEditor({ + initialContent, + onReady, +}: { + initialContent: PartialBlock[]; + onReady: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote({ initialContent }); + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + return ; +} + +export async function renderTestEditor(initialContent: PartialBlock[]) { + let editor!: BlockNoteEditor; + await render( + { + editor = instance; + }} + />, + ); + await expect.element(page.getByRole("textbox")).toBeVisible(); + await document.fonts.ready; + editor.focus(); + return editor; +} + +const screenReader = + server.config.name === "voiceover" || server.config.name === "nvda"; + +// Start with the editor focused after renderTestEditor(). Keep the navigation steps +// identical to focusToolbarItemWithAssertions in the keyboard suite. +export async function focusToolbarItem(toolbarItem: string) { + await commands.pressKey("ArrowDown"); + await commands.pressKey(selectToLineEndKey); + const toolbar = page.getByRole("toolbar"); + await commands.pressKey("Tab"); + const target = toolbar.getByRole("button", { + name: toolbarItem, + exact: true, + }); + for ( + let presses = 0; + document.activeElement !== target.element(); + presses++ + ) { + if (presses >= 20) { + throw new Error( + `Could not focus toolbar item "${toolbarItem}" after 20 Tab presses`, + ); + } + await commands.pressKey("Tab"); + } + return { toolbar, button: target }; +} + +// The middle paragraph is selected; its neighbours provide navigation context. +export const toolbarDocument: PartialBlock[] = [ + { id: "preceding", type: "paragraph", content: "Preceding paragraph" }, + { id: "format", type: "paragraph", content: "Format this text" }, + { id: "following", type: "paragraph", content: "Following paragraph" }, +]; + +const nestedToolbarDocument: PartialBlock[] = [ + { + id: "preceding", + type: "paragraph", + content: "Preceding paragraph", + children: [ + { id: "format", type: "paragraph", content: "Format this text" }, + ], + }, +]; + +// Left alignment must change the selected paragraph rather than reapply its default. +const centeredToolbarDocument: PartialBlock[] = [ + { id: "preceding", type: "paragraph", content: "Preceding paragraph" }, + { + id: "format", + type: "paragraph", + props: { textAlignment: "center" }, + content: "Format this text", + }, + { id: "following", type: "paragraph", content: "Following paragraph" }, +]; + +// Screen-reader tests import the shared helpers and fixtures from this file. +// Register these suites only in the regular browser run. +if (!screenReader) { + test.describe("behavior", () => { + test("formatting toolbar: Bold", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Bold"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Italic", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Italic"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Underline", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Underline"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Strike", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Strike"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Align text left", async ({ onTestFinished }) => { + const editor = await renderTestEditor(centeredToolbarDocument); + await focusToolbarItem("Align text left"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Align text center", async ({ + onTestFinished, + }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Align text center"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Align text right", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Align text right"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Colors", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Colors"); + await commands.pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await commands.pressKey("ArrowDown"); + await commands.pressKey("ArrowDown"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Nest block", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Nest block"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Unnest block", async ({ onTestFinished }) => { + const editor = await renderTestEditor(nestedToolbarDocument); + await focusToolbarItem("Unnest block"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Paragraph", async ({ onTestFinished }) => { + const editor = await renderTestEditor(toolbarDocument); + await focusToolbarItem("Paragraph"); + await commands.pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await commands.pressKey("ArrowDown"); + await commands.pressKey("ArrowDown"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await commands.pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + }); + + test.describe("keyboard", () => { + // Same navigation as the shared focusToolbarItem, with focus assertions. + async function focusToolbarItemWithAssertions(toolbarItem: string) { + const editor = page.getByRole("textbox"); + await expect.element(editor).toHaveFocus(); + await commands.pressKey("ArrowDown"); + await commands.pressKey(selectToLineEndKey); + const toolbar = page.getByRole("toolbar"); + await expect.element(toolbar).toBeVisible(); + const target = toolbar.getByRole("button", { + name: toolbarItem, + exact: true, + }); + const unfocusedOutline = await commands.readOutline(target.selector); + await commands.pressKey("Tab"); + await expect + .poll(() => toolbar.element().contains(document.activeElement)) + .toBe(true); + for ( + let presses = 0; + document.activeElement !== target.element(); + presses++ + ) { + if (presses >= 20) { + throw new Error( + `Could not focus toolbar item "${toolbarItem}" after 20 Tab presses`, + ); + } + await commands.pressKey("Tab"); + } + await expect.element(target).toHaveFocus(); + await commands.checkFocusOutline(target.selector, unfocusedOutline); + return { toolbar, button: target }; + } + + async function checkToolbarButtonKeyboard(toolbarButton: string) { + await focusToolbarItemWithAssertions(toolbarButton); + await commands.pressKey("Enter"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + } + + async function checkToolbarMenuKeyboard( + toolbarMenu: string, + item: () => ReturnType, + ) { + await focusToolbarItemWithAssertions(toolbarMenu); + await commands.pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + const firstItem = menu.getByRole("menuitem").first(); + const unfocusedOutline = await commands.readOutline(firstItem.selector); + await commands.pressKey("ArrowDown"); + await expect.element(firstItem).toHaveFocus(); + await commands.checkFocusOutline(firstItem.selector, unfocusedOutline); + const targetItem = item(); + const targetUnfocusedOutline = await commands.readOutline( + targetItem.selector, + ); + await commands.pressKey("ArrowDown"); + await expect.element(targetItem).toHaveFocus(); + await commands.checkFocusOutline( + targetItem.selector, + targetUnfocusedOutline, + ); + await commands.pressKey("Enter"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + } + + async function checkToolbarSelectKeyboard( + toolbarSelect: string, + selectedValue: string, + ) { + await focusToolbarItemWithAssertions(toolbarSelect); + await commands.pressKey("Enter"); + // Mantine implements the select using a menu and initially focuses a + // placeholder before the first option. + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + const firstItem = menu.getByRole("menuitem").first(); + const unfocusedOutline = await commands.readOutline(firstItem.selector); + await commands.pressKey("ArrowDown"); + await expect.element(firstItem).toHaveFocus(); + await commands.checkFocusOutline(firstItem.selector, unfocusedOutline); + const targetItem = menu.getByRole("menuitem", { + name: selectedValue, + exact: true, + }); + const targetUnfocusedOutline = await commands.readOutline( + targetItem.selector, + ); + await commands.pressKey("ArrowDown"); + await expect.element(targetItem).toHaveFocus(); + await commands.checkFocusOutline( + targetItem.selector, + targetUnfocusedOutline, + ); + await commands.pressKey("Enter"); + // Selects return focus to the trigger, unlike menus, which focus the editor. + await expect + .element(page.getByRole("button", { name: selectedValue, exact: true })) + .toHaveFocus(); + } + + async function checkToolbarPopoverKeyboard( + toolbarPopover: string, + input: ReturnType, + ) { + await focusToolbarItemWithAssertions(toolbarPopover); + await commands.pressKey("Enter"); + await expect.element(input).toHaveFocus(); + await commands.checkFocusOutline(input.selector); + await commands.pressKey("Escape"); + await expect.element(input).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + } + + test("formatting toolbar: Paragraph", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarSelectKeyboard("Paragraph", "Heading 1"); + }); + + test("formatting toolbar: Bold", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Bold"); + }); + + test("formatting toolbar: Italic", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Italic"); + }); + + test("formatting toolbar: Underline", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Underline"); + }); + + test("formatting toolbar: Strike", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Strike"); + }); + + test("formatting toolbar: Align text left", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Align text left"); + }); + + test("formatting toolbar: Align text center", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Align text center"); + }); + + test("formatting toolbar: Align text right", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Align text right"); + }); + + test("formatting toolbar: Colors", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarMenuKeyboard("Colors", () => + page.elementLocator( + document.querySelector('[data-test="text-color-gray"]')!, + ), + ); + }); + + test("formatting toolbar: Nest block", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Nest block"); + }); + + test("formatting toolbar: Unnest block", async () => { + await renderTestEditor(nestedToolbarDocument); + await checkToolbarButtonKeyboard("Unnest block"); + }); + + // The autofocused URL input has outline-style: none in all three browsers. + // Enable once the input has a visible keyboard focus outline. + test.skip("formatting toolbar: Create link", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarPopoverKeyboard( + "Create link", + page.getByPlaceholder("Edit URL"), + ); + }); + }); + + test.describe("ARIA", () => { + async function checkToolbarButtonAria(toolbarButton: string) { + const { button } = await focusToolbarItem(toolbarButton); + expect(await commands.readAriaSnapshot(button.selector)).toMatchSnapshot( + "before activation", + ); + await commands.pressKey("Enter"); + expect(await commands.readAriaSnapshot(button.selector)).toMatchSnapshot( + "after activation", + ); + } + + async function checkToolbarMenuAria( + toolbarMenu: string, + activatedName: string, + ) { + const { toolbar, button } = await focusToolbarItem(toolbarMenu); + expect(await commands.readAriaSnapshot(button.selector)).toMatchSnapshot( + "before activation", + ); + await commands.pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await commands.pressKey("ArrowDown"); + await commands.pressKey("ArrowDown"); + await commands.pressKey("Enter"); + expect( + await commands.readAriaSnapshot( + toolbar.getByRole("button", { name: activatedName, exact: true }) + .selector, + ), + ).toMatchSnapshot("after activation"); + } + + test("formatting toolbar: complete accessibility tree", async () => { + await renderTestEditor(toolbarDocument); + const { toolbar } = await focusToolbarItem("Paragraph"); + expect(await commands.readAriaSnapshot(toolbar.selector)).toMatchSnapshot( + "toolbar", + ); + }); + + test("formatting toolbar: Paragraph", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarMenuAria("Paragraph", "Heading 1"); + }); + + test("formatting toolbar: Bold", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Bold"); + }); + + test("formatting toolbar: Italic", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Italic"); + }); + + test("formatting toolbar: Underline", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Underline"); + }); + + test("formatting toolbar: Strike", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Strike"); + }); + + test("formatting toolbar: Align text left", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Align text left"); + }); + + test("formatting toolbar: Align text center", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Align text center"); + }); + + test("formatting toolbar: Align text right", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Align text right"); + }); + + test("formatting toolbar: Colors", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarMenuAria("Colors", "Colors"); + }); + + test("formatting toolbar: Nest block", async () => { + await renderTestEditor(toolbarDocument); + await checkToolbarButtonAria("Nest block"); + }); + + test("formatting toolbar: Unnest block", async () => { + await renderTestEditor(nestedToolbarDocument); + await checkToolbarButtonAria("Unnest block"); + }); + }); + + test.describe("axe", () => { + test("formatting toolbar: axe", async ({ annotate }) => { + await renderTestEditor(toolbarDocument); + await focusToolbarItem("Paragraph"); + for (const warning of await commands.checkAxe('[role="toolbar"]')) { + await annotate(warning, "warning"); + } + }); + + // Mantine inserts a focusable div inside the menu, triggering axe aria-required-children violations. + test.skip("formatting toolbar: block type menu axe", async ({ + annotate, + }) => { + await renderTestEditor(toolbarDocument); + await focusToolbarItem("Paragraph"); + await commands.pressKey("Enter"); + await expect.element(page.getByRole("menu")).toBeVisible(); + for (const warning of await commands.checkAxe('[role="menu"]')) { + await annotate(warning, "warning"); + } + }); + + // Mantine inserts a focusable div inside the menu, triggering axe aria-required-children violations. + test.skip("formatting toolbar: colors menu axe", async ({ annotate }) => { + await renderTestEditor(toolbarDocument); + await focusToolbarItem("Colors"); + await commands.pressKey("Enter"); + await expect.element(page.getByRole("menu")).toBeVisible(); + for (const warning of await commands.checkAxe('[role="menu"]')) { + await annotate(warning, "warning"); + } + }); + + test("formatting toolbar: link popover axe", async ({ annotate }) => { + await renderTestEditor(toolbarDocument); + await focusToolbarItem("Create link"); + await commands.pressKey("Enter"); + await expect.element(page.getByPlaceholder("Edit URL")).toBeVisible(); + for (const warning of await commands.checkAxe(".bn-form-popover")) { + await annotate(warning, "warning"); + } + }); + }); +} diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.screen-reader.test.ts b/packages/react/src/components/FormattingToolbar/FormattingToolbar.screen-reader.test.ts new file mode 100644 index 0000000000..dc778946bf --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.screen-reader.test.ts @@ -0,0 +1,30 @@ +import { commands } from "@root/tests/src/utils/context.js"; +import { afterEach, expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { + renderTestEditor, + focusToolbarItem, + toolbarDocument, +} from "./FormattingToolbar.browser.test.js"; + +afterEach(async () => { + try { + const transcript = await commands.readTranscript(); + expect(transcript.length).toBeGreaterThan(0); + expect(transcript).toMatchSnapshot(); + } finally { + await commands.stopReader(); + } +}); + +// Record selection → toolbar → Escape → editor. +// Individual toolbar item actions and focus rings are covered by the browser tests. +// Screen reader announcements do not clearly communicate entering the toolbar. +test.skip("formatting toolbar: open, focus, and close", async () => { + await renderTestEditor(toolbarDocument); + await commands.startReader(page.getByRole("textbox").selector); + const { toolbar } = await focusToolbarItem("Paragraph"); + await commands.pressKey("Escape"); + await expect.element(toolbar).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); +}); diff --git a/packages/react/src/components/FormattingToolbar/__snapshots__/FormattingToolbar.browser.test.tsx.snap b/packages/react/src/components/FormattingToolbar/__snapshots__/FormattingToolbar.browser.test.tsx.snap new file mode 100644 index 0000000000..d1a95798a1 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/__snapshots__/FormattingToolbar.browser.test.tsx.snap @@ -0,0 +1,135 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ARIA > formatting toolbar: Align text center > after activation 1`] = ` +"- button "Align text center" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text center > before activation 1`] = ` +"- button "Align text center": + - img" +`; + +exports[`ARIA > formatting toolbar: Align text left > after activation 1`] = ` +"- button "Align text left" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text left > before activation 1`] = ` +"- button "Align text left" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text right > after activation 1`] = ` +"- button "Align text right" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text right > before activation 1`] = ` +"- button "Align text right": + - img" +`; + +exports[`ARIA > formatting toolbar: Bold > after activation 1`] = ` +"- button "Bold" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Bold > before activation 1`] = ` +"- button "Bold": + - img" +`; + +exports[`ARIA > formatting toolbar: Colors > after activation 1`] = `"- button "Colors": A"`; + +exports[`ARIA > formatting toolbar: Colors > before activation 1`] = `"- button "Colors": A"`; + +exports[`ARIA > formatting toolbar: Italic > after activation 1`] = ` +"- button "Italic" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Italic > before activation 1`] = ` +"- button "Italic": + - img" +`; + +exports[`ARIA > formatting toolbar: Nest block > after activation 1`] = ` +"- button "Nest block" [disabled]: + - img" +`; + +exports[`ARIA > formatting toolbar: Nest block > before activation 1`] = ` +"- button "Nest block": + - img" +`; + +exports[`ARIA > formatting toolbar: Paragraph > after activation 1`] = ` +"- button "Heading 1": + - img + - text: Heading 1" +`; + +exports[`ARIA > formatting toolbar: Paragraph > before activation 1`] = ` +"- button "Paragraph": + - img + - text: Paragraph" +`; + +exports[`ARIA > formatting toolbar: Strike > after activation 1`] = ` +"- button "Strike" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Strike > before activation 1`] = ` +"- button "Strike": + - img" +`; + +exports[`ARIA > formatting toolbar: Underline > after activation 1`] = ` +"- button "Underline" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Underline > before activation 1`] = ` +"- button "Underline": + - img" +`; + +exports[`ARIA > formatting toolbar: Unnest block > after activation 1`] = ` +"- button "Unnest block" [disabled]: + - img" +`; + +exports[`ARIA > formatting toolbar: Unnest block > before activation 1`] = ` +"- button "Unnest block": + - img" +`; + +exports[`ARIA > formatting toolbar: complete accessibility tree > toolbar 1`] = ` +"- toolbar: + - button "Paragraph": + - img + - text: Paragraph + - button "Bold": + - img + - button "Italic": + - img + - button "Underline": + - img + - button "Strike": + - img + - button "Align text left" [pressed]: + - img + - button "Align text center": + - img + - button "Align text right": + - img + - button "Colors": A + - button "Nest block": + - img + - button "Unnest block" [disabled]: + - img + - button "Create link": + - img" +`; diff --git a/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx b/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx index 1a8ca8663b..c0cedd8dae 100644 --- a/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx +++ b/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx @@ -1654,8 +1654,8 @@ describe("VersioningSidebar", () => { } return ( { - void action.execute(); + onClick={async () => { + await action.execute(); }} > Roll back @@ -2109,6 +2109,7 @@ describe("VersioningSidebar", () => { }); expect(rows()[1]!.getAttribute("aria-busy")).toBe("true"); await act(async () => { + // oxlint-disable-next-line vitest/valid-expect -- Attach the rejection handler before rejecting; awaited below. const rejected = expect(pending).rejects.toBe(cause); rejectContent(cause); await rejected; @@ -2542,6 +2543,7 @@ describe("VersioningSidebar", () => { ); const ext = mode(editor); const cause = new Error("old naming failed"); + // oxlint-disable-next-line vitest/valid-expect -- Observe rejection before switching rows; awaited after rejectName below. const rejected = expect(run(() => ext.create("Draft"))).rejects.toBe(cause); await click(rows()[1]!); await act(async () => { diff --git a/packages/react/src/editor/BlockNoteView.browser.test.tsx b/packages/react/src/editor/BlockNoteView.browser.test.tsx new file mode 100644 index 0000000000..651cf0b846 --- /dev/null +++ b/packages/react/src/editor/BlockNoteView.browser.test.tsx @@ -0,0 +1,770 @@ +import { commands } from "@root/tests/src/utils/context.js"; +import { + COLORS_DEFAULT, + defaultBlockSpecs, + type PartialBlock, + type BlockNoteEditor, + type DefaultBlockSchema, + type PartialInlineContentElement, + type Styles, + type PartialTableCell, + type PartialTableContent, + type DefaultInlineContentSchema, + type DefaultStyleSchema, + type PropSchema, +} from "@blocknote/core"; +import { expect, test } from "vite-plus/test"; +import { page, server } from "vite-plus/test/browser"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { render } from "vitest-browser-react"; +import { useEffect } from "react"; +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +import { screenshotFull } from "@root/tests/src/utils/screenshotFull.js"; + +function TestEditor({ + initialContent, + editable, + theme, + onReady, +}: { + initialContent: PartialBlock[]; + editable: boolean; + theme: "light" | "dark"; + onReady: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote({ initialContent }); + useEffect(() => onReady(editor), [editor, onReady]); + return ( +
+

BlockNote accessibility fixture

+ + + +
+ ); +} + +export async function renderTestEditor( + initialContent: PartialBlock[], + { + editable = true, + theme = "light", + }: { + editable?: boolean; + theme?: "light" | "dark"; + } = {}, +) { + let editor: BlockNoteEditor | undefined; + await page.viewport(1280, 900); + await render( + { + editor = instance; + }} + theme={theme} + />, + ); + await expect.element(page.getByRole("textbox")).toBeVisible(); + await expect + .element(page.getByRole("textbox")) + .toHaveAttribute("contenteditable", String(editable)); + await document.fonts.ready; + page + .getByRole("button", { name: "Before editor", exact: true }) + .element() + .focus(); + if (!editor) { + throw new Error("Test editor did not mount"); + } + return editor; +} + +const screenReader = + server.config.name === "voiceover" || server.config.name === "nvda"; + +const styleExamples = { + bold: { bold: true }, + italic: { italic: true }, + underline: { underline: true }, + strike: { strike: true }, + code: { code: true }, + textColor: { textColor: "blue" }, + backgroundColor: { backgroundColor: "yellow" }, +} satisfies { + [K in keyof DefaultStyleSchema]: Required< + Pick, K> + >; +}; + +const inlineExamples = { + text: { type: "text", text: "Plain text. ", styles: {} }, + link: { + type: "link", + href: "https://www.blocknotejs.org/", + content: "BlockNote documentation", + }, +} satisfies Record< + keyof DefaultInlineContentSchema, + PartialInlineContentElement +>; + +const blockExamples = { + paragraph: { + type: "paragraph", + content: [ + ...Object.values(inlineExamples), + // Use separate text spans because code excludes other formatting marks. + ...Object.entries(styleExamples).map(([name, styles]) => ({ + type: "text" as const, + text: ` ${name}.`, + styles, + })), + ], + }, + heading: { + type: "heading", + props: { level: 1 }, + content: "Heading level one", + }, + bulletListItem: { type: "bulletListItem", content: "Bullet list item" }, + numberedListItem: { type: "numberedListItem", content: "Numbered list item" }, + checkListItem: { type: "checkListItem", content: "Unchecked task" }, + toggleListItem: { + type: "toggleListItem", + content: "Toggle list item", + children: [{ type: "paragraph", content: "Toggle child" }], + }, + quote: { type: "quote", content: "Quoted text" }, + codeBlock: { type: "codeBlock", content: "const answer = 42;" }, + divider: { type: "divider" }, + table: { + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { cells: ["Name", "Description"] }, + { cells: ["Example", "Table cell"] }, + ], + }, + }, + image: { + type: "image", + props: { + url: "/image.svg", + name: "Example image", + caption: "Blue square", + previewWidth: 100, + }, + }, + audio: { + type: "audio", + props: { url: "/tone.wav", name: "tone.wav", caption: "A short test tone" }, + }, + video: { + type: "video", + props: { + url: "/video.webm", + name: "video.webm", + caption: "A flower moving in the breeze", + previewWidth: 200, + }, + }, + file: { type: "file", props: { url: "/example.txt", name: "example.txt" } }, +} satisfies { [K in keyof DefaultBlockSchema]: PartialBlock & { type: K } }; + +export const defaultSchemaDocument: PartialBlock[] = [ + { id: "start", type: "paragraph", content: "Start of document" }, + ...Object.entries(blockExamples).map(([id, block]): PartialBlock => { + if ("children" in block) { + return { + ...block, + id, + children: block.children.map((child, index) => ({ + ...child, + id: `${id}-child-${index}`, + })), + }; + } + return { ...block, id }; + }), + ...[2, 3, 4, 5, 6].map((level) => ({ + id: `heading-${level}`, + type: "heading" as const, + props: { level }, + content: `Heading level ${level}`, + })), + { + id: "toggle-heading", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Toggle heading", + children: [ + { + id: "toggle-heading-child", + type: "paragraph", + content: "Heading child", + }, + ], + }, + { + id: "checked", + type: "checkListItem", + props: { checked: true }, + content: "Completed task", + }, + { id: "end", type: "paragraph", content: "End of document" }, +]; + +const tableNavigationDocument: PartialBlock[] = [ + { ...blockExamples.table, id: "table" }, +]; + +const readOnlyLinkDocument: PartialBlock[] = [ + { + type: "paragraph", + content: [ + { + type: "link", + href: "https://www.blocknotejs.org/", + content: "Documentation", + }, + ], + }, +]; + +const editorEscapeDocument: PartialBlock[] = [ + { type: "paragraph", content: "Editable text" }, +]; + +// Each block gets its own base and one variant per individual prop value. +// Required strings/numbers use the example/default; optional ones additionally +// cover undefined. Theme colors are the exception: their foreground/background +// combinations are covered separately below to check contrast. +function propValues( + spec: PropSchema[string], + baseValue: string | number | boolean | undefined, +): readonly (string | number | boolean | undefined)[] { + if (spec.values) { + return spec.default === undefined + ? [undefined, ...spec.values] + : spec.values; + } + if (typeof spec.default === "boolean") { + return [false, true]; + } + if (spec.default !== undefined) { + return [baseValue]; + } + switch (spec.type) { + case "boolean": + return [undefined, false, true]; + case "number": + return [undefined, baseValue ?? 1]; + case "string": + return [undefined, baseValue ?? "Example text"]; + } +} + +type Cell = PartialTableCell; +function cell(content: string, props: Cell["props"] = {}): Cell { + return { type: "tableCell", content, props }; +} + +const baseTableContent = { + type: "tableContent", + rows: [ + { cells: [cell("Name"), cell("Description")] }, + { cells: [cell("Example"), cell("Table cell")] }, + ], +} satisfies PartialTableContent; + +const colors = ["default", ...Object.keys(COLORS_DEFAULT)]; +const colorPairs = colors.flatMap((textColor) => + colors.map((backgroundColor) => ({ textColor, backgroundColor })), +); + +const schemaDocument: PartialBlock[] = [ + ...Object.values(blockExamples).flatMap((example): PartialBlock[] => { + const block: PartialBlock = example; + const props: Record = + block.props ?? {}; + const propSchema = defaultBlockSpecs[example.type].config.propSchema; + const colorVariants: Record[] = + "textColor" in propSchema && "backgroundColor" in propSchema + ? colorPairs + : "textColor" in propSchema + ? colors.map((textColor) => ({ textColor })) + : "backgroundColor" in propSchema + ? colors.map((backgroundColor) => ({ backgroundColor })) + : []; + return [ + { ...block, id: `schema-${example.type}-base` }, + ...colorVariants.flatMap((colorProps, index): PartialBlock[] => { + const isBase = Object.entries(colorProps).every( + ([name, value]) => value === (props[name] ?? "default"), + ); + return isBase + ? [] + : [ + { + ...block, + id: `schema-${example.type}-colors-${index}`, + props: { ...props, ...colorProps }, + }, + ]; + }), + ...Object.entries(propSchema).flatMap(([name, spec]) => { + const baseValue = props[name] ?? spec.default; + return propValues(spec, baseValue).flatMap((value, index) => + value === baseValue + ? [] + : [ + { + ...block, + id: `schema-${example.type}-${name}-${index}`, + props: { ...block.props, [name]: value }, + }, + ], + ); + }), + ]; + }), + // Plain content covers absent styles (including false boolean styles). + // Apply one style at a time to both text and link content; code cannot be + // combined with the other formatting marks. + ...[{}, ...Object.values(styleExamples)].map( + (styles, index): PartialBlock => ({ + id: `schema-inline-${index}`, + type: "paragraph", + content: [ + { type: "text", text: "Styled text. ", styles }, + { + type: "link", + href: inlineExamples.link.href, + content: [{ type: "text", text: "Styled link", styles }], + }, + ], + }), + ), + // Inline marks and cell props have separate rendering paths from block + // colors. Check every palette pair on each path, including linked text. + ...colorPairs.map((styles, index): PartialBlock => ({ + id: `schema-inline-colors-${index}`, + type: "paragraph", + content: [ + { + type: "text", + text: `${styles.textColor} on ${styles.backgroundColor}. `, + styles, + }, + { + type: "link", + href: inlineExamples.link.href, + content: [{ type: "text", text: "Colored link", styles }], + }, + ], + })), + { + id: "schema-table-colors", + type: "table", + content: { + type: "tableContent", + rows: colorPairs.map((props) => ({ + cells: [cell(`${props.textColor} on ${props.backgroundColor}`, props)], + })), + }, + }, + // Table structure lives in content, outside the block prop schema. Start + // without headers/widths and vary each optional field independently. + ...[ + baseTableContent, + { ...baseTableContent, headerRows: 1 }, + { ...baseTableContent, headerCols: 1 }, + { ...baseTableContent, columnWidths: [150, undefined] }, + // Cell color pairs are covered above; vary alignment independently here. + ...defaultBlockSpecs.paragraph.config.propSchema.textAlignment.values + .filter((textAlignment) => textAlignment !== "left") + .map((textAlignment) => ({ + ...baseTableContent, + rows: baseTableContent.rows.map((row, rowIndex) => ({ + cells: row.cells.map((tableCell, columnIndex) => + rowIndex === 0 && columnIndex === 0 + ? { ...tableCell, props: { ...tableCell.props, textAlignment } } + : tableCell, + ), + })), + })), + // Changing a span also requires removing the cells it occupies to keep + // the table valid. Cover each optional span independently. + { + ...baseTableContent, + rows: [ + { cells: [cell("Spans columns", { colspan: 2 })] }, + { cells: [cell("Left"), cell("Right")] }, + ], + }, + { + ...baseTableContent, + rows: [ + { cells: [cell("Spans rows", { rowspan: 2 }), cell("Top")] }, + { cells: [cell("Bottom")] }, + ], + }, + ].map((content, index): PartialBlock => ({ + id: `schema-table-content-${index}`, + type: "table", + content, + })), +]; + +// Screen-reader tests import the shared helpers and fixtures from this file. +// Register these suites only in the regular browser run. +if (!screenReader) { + test.describe("keyboard", () => { + async function focusReadOnlyLinkWithAssertions() { + const link = page.getByRole("link", { + name: "Documentation", + exact: true, + }); + await expect.element(link).toBeVisible(); + const unfocusedOutline = await commands.readOutline(link.selector); + for ( + let presses = 0; + document.activeElement !== link.element(); + presses++ + ) { + if (presses >= 10) { + throw new Error( + "Could not focus the Documentation link after 10 Tab presses", + ); + } + await commands.pressKey("Tab"); + } + await expect.element(link).toHaveFocus(); + await commands.checkFocusOutline(link.selector, unfocusedOutline); + return link; + } + + // Firefox skips checkListItem with ArrowDown. + // Chromium/WebKit reproduction: expand "Toggle heading", place the caret + // immediately before the C in "Completed task", then press ArrowUp. It lands + // in "Toggle heading", skipping the visible "Heading child". ArrowUp from + // the end of "Completed task" works; Firefox passes both caret positions. + // Confirmed with clicks and keypresses in Docker. The traversal below reaches + // "Completed task" at offset 0 when returning from the final paragraph. + // Keep the complete expected route when re-enabling this test. + test.skip("default schema: arrow keys visit every expanded block in order", async ({ + onTestFinished, + }) => { + // Expansion is view state, not a document prop. Make the route deterministic + // without changing the visual fixtures' default collapsed state. + for (const id of ["toggleListItem", "toggle-heading"]) { + const key = `toggle-${id}`; + const previous = localStorage.getItem(key); + localStorage.setItem(key, "true"); + onTestFinished(() => { + if (previous === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, previous); + } + }); + } + const editor = await renderTestEditor(defaultSchemaDocument); + await commands.pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + + function blockIds(blocks: PartialBlock[]): string[] { + return blocks.flatMap((block) => { + if (!block.id) { + throw new Error("Navigation fixture blocks must have explicit IDs"); + } + return [block.id, ...blockIds(block.children ?? [])]; + }); + } + const route = blockIds(defaultSchemaDocument); + // The editor selection also identifies atom blocks (divider/file/media), + // whose NodeSelection need not have a DOM text selection inside the block. + expect(editor.getTextCursorPosition().block.id).toBe(route[0]); + for (const key of ["ArrowDown", "ArrowUp"] as const) { + const expected = key === "ArrowDown" ? route : route.toReversed(); + let index = 0; + for (let presses = 0; index < expected.length - 1; presses++) { + if (presses >= 100) { + throw new Error( + `${key} did not finish the document route after 100 presses; still at block "${expected[index]}"`, + ); + } + await commands.pressKey(key); + const actual = editor.getTextCursorPosition().block.id; + + expect( + [expected[index], expected[index + 1]], + `${key}: expected ${expected[index]} → ${expected[index + 1]}, got ${actual}`, + ).toContain(actual); + const selection = editor.prosemirrorState.selection; + if ( + actual === expected[index + 1] && + (selection instanceof TextSelection || + selection instanceof NodeSelection) + ) { + index++; + } + } + expect(editor.getTextCursorPosition().block.id).toBe(expected.at(-1)); + } + }); + + test("table: ArrowRight and ArrowLeft visit every cell in row order", async () => { + await renderTestEditor(tableNavigationDocument); + await commands.pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + const route = [ + { row: 0, column: 0 }, + { row: 0, column: 1 }, + { row: 1, column: 0 }, + { row: 1, column: 1 }, + ]; + expect((await commands.readSelection()).tableCell).toEqual(route[0]); + for (const key of ["ArrowRight", "ArrowLeft"] as const) { + const expected = key === "ArrowRight" ? route : route.toReversed(); + let index = 0; + for (let presses = 0; index < expected.length - 1; presses++) { + if (presses >= 100) { + throw new Error( + `${key} did not finish the table route after 100 presses; still at cell ${JSON.stringify(expected[index])}`, + ); + } + await commands.pressKey(key); + const { blockId, tableCell } = await commands.readSelection(); + expect(blockId, `${key} stays in the table`).toBe("table"); + expect( + [expected[index], expected[index + 1]], + `${key}: cell ${JSON.stringify(expected[index])} → ${JSON.stringify(expected[index + 1])}`, + ).toContainEqual(tableCell); + if ( + tableCell?.row === expected[index + 1].row && + tableCell?.column === expected[index + 1].column + ) { + index++; + } + } + } + }); + + // After Tab enters the second column, Chromium/WebKit's ArrowDown returns + // to column 0. Firefox preserves the column throughout this sequence. + test.skipIf(server.browser !== "firefox")( + "table: ArrowDown and ArrowUp visit each row without changing column", + async () => { + await renderTestEditor(tableNavigationDocument); + await commands.pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + for (const column of [0, 1]) { + expect((await commands.readSelection()).tableCell).toEqual({ + row: 0, + column, + }); + for (const key of ["ArrowDown", "ArrowUp"] as const) { + const targetRow = key === "ArrowDown" ? 1 : 0; + let selection = await commands.readSelection(); + for ( + let presses = 0; + selection.tableCell?.row !== targetRow; + presses++ + ) { + if (presses >= 10) { + throw new Error( + `${key} did not reach row ${targetRow}, column ${column} after 10 presses`, + ); + } + await commands.pressKey(key); + selection = await commands.readSelection(); + expect(selection.blockId, `${key} stays in the table`).toBe( + "table", + ); + expect( + selection.tableCell?.column, + `${key} stays in column ${column}`, + ).toBe(column); + expect([0, 1]).toContain(selection.tableCell?.row); + } + expect(selection.tableCell).toEqual({ row: targetRow, column }); + } + if (column === 0) { + // Set up the second column through the table's keyboard navigation. + await commands.pressKey("Tab"); + } + } + }, + ); + + // The editor has no separate focused state without an editing selection; Escape blurs to the body. + test.skip("editor can receive focus without selecting content", async () => { + // TODO: Focus the editor as a page element without an editing selection. + // Tab/arrows should navigate the page; Enter should enter editing. + // Escape currently blurs to body, which does not preserve this entry point. + }); + + // Whole-block selection requires Cmd+Click; no keyboard interaction is implemented. No styling + // is present to indicate whole-block selection. + test.skip("keyboard selection extends to the entire block", async () => { + // TODO: Extend a text selection to its whole block, leaving neighbours out. + // Whole-block selection currently requires Cmd+Click; keyboard UX is TBD. + }); + + // TODO: WebKit resets the tab position after Escape blurs the editor, so Tab + // focuses "Before editor" instead of "After editor". + test.skipIf(server.browser === "webkit")( + "Escape allows Tab to leave the editor", + async () => { + await renderTestEditor(editorEscapeDocument); + await commands.pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + await commands.pressKey("Escape"); + await expect.element(document.body).toHaveFocus(); + await commands.pressKey("Tab"); + await expect + .element( + page.getByRole("button", { name: "After editor", exact: true }), + ) + .toHaveFocus(); + // Revisit when the editor can retain focus without an editing selection. + }, + ); + + // Focused inline links do not yet have a suitable visual focus indicator. + // TODO: Fix the focus appearance before enabling. + test.skip("read-only editor: Tab reaches inline links and leaves the editor", async () => { + await renderTestEditor(readOnlyLinkDocument, { editable: false }); + const link = await focusReadOnlyLinkWithAssertions(); + await commands.pressKey("Tab"); + await expect + .element( + page.getByRole("button", { name: "After editor", exact: true }), + ) + .toHaveFocus(); + await commands.pressKey("Shift+Tab"); + await expect.element(link).toHaveFocus(); + }); + + // Read-only file/media blocks cannot be selected through the page tab order. + test.skip("read-only editor: Tab selects blocks that expose a formatting toolbar", async () => { + // TODO: Open a read-only document containing an image/file block between + // ordinary paragraphs. Tab from Before editor to select that block, assert + // the block selection and visible indicator, and continue to After editor. + // Repeat with Shift+Tab. Static paragraphs should not introduce tab stops. + // Blocks currently cannot be selected through the page's tab order; do not + // work around this by clicking or setting the selection through editor APIs. + }); + }); + + test.describe("ARIA", () => { + // Numbered list item indices are separate text elements, among other issues. + test.skip("default schema: accessibility tree snapshot", async () => { + await renderTestEditor(schemaDocument); + expect( + await commands.readAriaSnapshot(page.getByRole("textbox").selector), + ).toMatchSnapshot("default schema"); + }); + }); + + test.describe("axe", () => { + for (const theme of ["light", "dark"] as const) { + // Known issues include the unnamed editor, unlabeled checklist checkboxes, + // insufficient contrast, and undersized targets. + test.skip(`default schema: axe (${theme})`, async ({ annotate }) => { + await renderTestEditor(schemaDocument, { theme }); + for (const warning of await commands.checkAxe()) { + await annotate(warning, "warning"); + } + }); + } + }); + + test.describe("visual", () => { + function applyStyles(css: string) { + const style = document.createElement("style"); + style.textContent = css; + page.getByRole("main").element().append(style); + } + + // Scaling text takes some logic as the scaling has to be applied to each font + // size, not just the base editor font size. + function resizeText(scale: number) { + function rulesFor(element: Element, selector: string): string { + const rule = + element instanceof HTMLElement + ? `${selector} { font-size: ${Number.parseFloat(getComputedStyle(element).fontSize) * scale}px !important; }` + : ""; + return [ + rule, + ...Array.from(element.children, (child, index) => + rulesFor(child, `${selector} > :nth-child(${index + 1})`), + ), + ].join("\n"); + } + + // Read all sizes before applying overrides, so inherited sizes scale once. + // A stylesheet avoids ProseMirror reconciling mutations to editable DOM. + applyStyles(rulesFor(page.getByRole("textbox").element(), ".bn-editor")); + } + + async function waitForMedia() { + // Wait for current-frame media data before the tests take screenshots. + for (const media of document.querySelectorAll("audio, video")) { + if (media instanceof HTMLMediaElement) { + media.preload = "auto"; + media.load(); + await expect.poll(() => media.readyState).toBeGreaterThanOrEqual(2); + } + } + } + + test.describe("text spacing and resizing", () => { + test("default schema remains readable with text spacing overrides", async () => { + await renderTestEditor(defaultSchemaDocument); + await waitForMedia(); + // WCAG 1.4.12: override spacing without changing any other style property. + // https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html + applyStyles(` + .bn-editor, .bn-editor * { + line-height: 1.5 !important; + letter-spacing: 0.12em !important; + word-spacing: 0.16em !important; + } + .bn-editor p { margin-block-end: 2em !important; } + `); + await screenshotFull(document.body, "default-schema-text-spacing.png", { + comparatorOptions: { allowedMismatchedPixels: 10 }, + // Video decoding differs across platforms; keep captions and layout in scope. + screenshotOptions: { + mask: [page.elementLocator(document.querySelector("video")!)], + }, + }); + }); + + test("default schema remains readable at 200% text size", async () => { + await renderTestEditor(defaultSchemaDocument); + await waitForMedia(); + resizeText(2); + await screenshotFull( + document.body, + "default-schema-text-size-200-percent.png", + { + comparatorOptions: { allowedMismatchedPixels: 10 }, + screenshotOptions: { + mask: [page.elementLocator(document.querySelector("video")!)], + }, + }, + ); + }); + }); + }); +} diff --git a/packages/react/src/editor/BlockNoteView.screen-reader.test.ts b/packages/react/src/editor/BlockNoteView.screen-reader.test.ts new file mode 100644 index 0000000000..0d6864e567 --- /dev/null +++ b/packages/react/src/editor/BlockNoteView.screen-reader.test.ts @@ -0,0 +1,70 @@ +import { commands } from "@root/tests/src/utils/context.js"; +import { page } from "vite-plus/test/browser"; +import { afterEach, expect, test } from "vite-plus/test"; +import { + renderTestEditor, + defaultSchemaDocument, +} from "./BlockNoteView.browser.test.js"; + +afterEach(async () => { + try { + const transcript = await commands.readTranscript(); + expect(transcript.length).toBeGreaterThan(0); + expect(transcript).toMatchSnapshot(); + } finally { + await commands.stopReader(); + } +}); + +// Navigation announcements omit block types, document structure, and a lot of other markup. +test.skip("default schema: arrow-key document navigation", async () => { + await renderTestEditor(defaultSchemaDocument); + await commands.startReader( + page.getByRole("button", { name: "Before editor", exact: true }).selector, + ); + await commands.pressKey("Tab"); + + // Allow repeated presses within text, but fail early if navigation gets stuck. + const visitedTableCells = new Set(); + for ( + let presses = 0; + (await commands.readSelection()).blockId !== "end"; + presses++ + ) { + if (presses >= 100) { + throw new Error( + "Could not reach the final block after 100 arrow-key presses", + ); + } + const { tableCell } = await commands.readSelection(); + if (tableCell) { + visitedTableCells.add(`${tableCell.row},${tableCell.column}`); + } + // ArrowDown alone skips columns. Move through cell text and across each + // row with ArrowRight, then resume ArrowDown from the final cell. + if (tableCell && visitedTableCells.size < 4) { + await commands.pressKey("ArrowRight"); + } else { + await commands.pressKey("ArrowDown"); + } + } + for ( + let presses = 0; + (await commands.readSelection()).blockId !== "start"; + presses++ + ) { + if (presses >= 100) { + throw new Error( + "Could not return to the first block after 100 ArrowUp presses", + ); + } + await commands.pressKey("ArrowUp"); + } +}); + +// Keyboard whole-block selection is not implemented, so its announcements cannot be tested yet. +test.skip("whole-block selection is announced", async () => { + // TODO: Once keyboard block selection exists, verify its type and scope are + // communicated, and review the transcript before accepting a baseline. + // TODO: Text selection, whole-document selection, and non-inline blocks. +}); diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-chromium-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-chromium-linux.png new file mode 100644 index 0000000000..f8e7420c3f Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-chromium-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-firefox-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-firefox-linux.png new file mode 100644 index 0000000000..23dfc09b3b Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-firefox-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-webkit-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-webkit-linux.png new file mode 100644 index 0000000000..195e0b19c8 Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-size-200-percent-webkit-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-chromium-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-chromium-linux.png new file mode 100644 index 0000000000..b3e735c0d7 Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-chromium-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-firefox-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-firefox-linux.png new file mode 100644 index 0000000000..e694db87bf Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-firefox-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-webkit-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-webkit-linux.png new file mode 100644 index 0000000000..9935b577a6 Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.browser.test.tsx/default-schema-text-spacing-webkit-linux.png differ diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 795b193d05..850528c0a7 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -22,6 +22,7 @@ "emitDeclarationOnly": true }, "include": ["src"], + "exclude": ["src/**/*.browser.test.*", "src/**/*.screen-reader.test.ts"], "references": [ { "path": "../core" diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 337545305b..5b94676267 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -34,7 +34,11 @@ export default defineConfig( setupFiles: ["./vitestSetup.ts"], // `.browser.test` files need a real browser; the tests package's // browser suite runs them. - exclude: [...configDefaults.exclude, "**/*.browser.test.*"], + exclude: [ + ...configDefaults.exclude, + "**/*.browser.test.*", + "**/*.screen-reader.test.ts", + ], }, plugins: [react(), webpackStats()], // used so that vitest resolves the core package from the sources instead of the built version diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1df42384e4..4f107a35cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6716,6 +6716,9 @@ importers: '@blocknote/xl-typst-exporter': specifier: workspace:^ version: link:../packages/xl-typst-exporter + '@guidepup/guidepup': + specifier: ^0.35.0 + version: 0.35.0 '@opentelemetry/api': specifier: 1.10.0 version: 1.10.0 @@ -6761,6 +6764,9 @@ importers: '@y/y': specifier: 14.0.0-rc.26 version: 14.0.0-rc.26 + axe-core: + specifier: 4.13.0 + version: 4.13.0 htmlfy: specifier: ^0.6.7 version: 0.6.7 @@ -7972,6 +7978,9 @@ packages: tailwindcss: optional: true + '@guidepup/guidepup@0.35.0': + resolution: {integrity: sha512-US64a++RucCtLfoq/DqnjzaHYpKzERVYmhtsWViz38MyklICICDpb50AKLn9sbxUwTceK2rS0fjDy7fYpWK/QA==} + '@handlewithcare/prosemirror-inputrules@0.1.4': resolution: {integrity: sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==} peerDependencies: @@ -11958,6 +11967,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} @@ -14852,6 +14865,10 @@ packages: engines: {node: '>=18'} hasBin: true + plist@4.0.0: + resolution: {integrity: sha512-4dOqNo0Y2NpfSf9q4+zr4bh7pzNWeckIam34Z0KYJhg8qtNNfh59VbD+Yna5SjwcxawVvLKx5w5FtuCijpEF4Q==} + engines: {node: '>=18'} + png-js@2.0.0: resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} @@ -16390,6 +16407,10 @@ packages: resolution: {integrity: sha512-TxyRxk9sTOUg3glxSIY6f0nfuqRll2OEF8TspLgh5mZkLuBgheCn3zClcDSGJ58TvNmiwyCCuat4UajPud/5Og==} engines: {node: '>= 16'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -17948,6 +17969,13 @@ snapshots: next: 16.3.0(@babel/core@7.29.7)(@opentelemetry/api@1.10.0)(@playwright/test@1.60.0)(@types/node@25.9.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwindcss: 4.2.2 + '@guidepup/guidepup@0.35.0': + dependencies: + debug: 4.4.3 + plist: 4.0.0 + transitivePeerDependencies: + - supports-color + '@handlewithcare/prosemirror-inputrules@0.1.4(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)': dependencies: prosemirror-history: 1.5.1 @@ -21890,6 +21918,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axe-core@4.13.0: {} + axios@1.15.0: dependencies: follow-redirects: 1.16.0 @@ -25118,6 +25148,11 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plist@4.0.0: + dependencies: + '@xmldom/xmldom': 0.9.10 + xmlbuilder: 15.1.1 + png-js@2.0.0: dependencies: fflate: 0.8.3 @@ -27021,6 +27056,8 @@ snapshots: xml-parser-xo@4.1.5: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} xtend@4.0.2: {} diff --git a/tests/docker-run.sh b/tests/docker-run.sh index 5001a82f4b..c10eeba65c 100755 --- a/tests/docker-run.sh +++ b/tests/docker-run.sh @@ -85,6 +85,7 @@ done # The test files and browser config (callers iterate on these too). mounts+=( -v "$PWD/tests/src:/work/tests/src" + -v "$PWD/tests/public:/work/tests/public" -v "$PWD/tests/vite.config.browser.ts:/work/tests/vite.config.browser.ts" -v "$PWD/tests/vitestSetup.browser.ts:/work/tests/vitestSetup.browser.ts" ) diff --git a/tests/package.json b/tests/package.json index ed28d1f8a2..26df4820f9 100644 --- a/tests/package.json +++ b/tests/package.json @@ -20,6 +20,7 @@ "@blocknote/xl-multi-column": "workspace:^", "@blocknote/xl-pdf-exporter": "workspace:^", "@blocknote/xl-typst-exporter": "workspace:^", + "@guidepup/guidepup": "^0.35.0", "@opentelemetry/api": "1.10.0", "@playwright/test": "1.60.0", "@shikijs/core": "^4.4.3", @@ -35,6 +36,7 @@ "@vitest/ui": "5.0.5", "@y/protocols": "^1.0.6-rc.1", "@y/y": "^14.0.0-rc.26", + "axe-core": "4.13.0", "htmlfy": "^0.6.7", "pdfjs-dist": "^4.10.38", "react": "^19.2.5", diff --git a/tests/public/example.txt b/tests/public/example.txt new file mode 100644 index 0000000000..72d985baa0 --- /dev/null +++ b/tests/public/example.txt @@ -0,0 +1 @@ +BlockNote accessibility test file. diff --git a/tests/public/image.svg b/tests/public/image.svg new file mode 100644 index 0000000000..a4e84046ae --- /dev/null +++ b/tests/public/image.svg @@ -0,0 +1 @@ + diff --git a/tests/public/tone.wav b/tests/public/tone.wav new file mode 100644 index 0000000000..77c826aa53 Binary files /dev/null and b/tests/public/tone.wav differ diff --git a/tests/public/video.webm b/tests/public/video.webm new file mode 100644 index 0000000000..5b8edf7d83 Binary files /dev/null and b/tests/public/video.webm differ diff --git a/tests/screen-reader/run-screen-reader.mjs b/tests/screen-reader/run-screen-reader.mjs new file mode 100644 index 0000000000..87bf9ae652 --- /dev/null +++ b/tests/screen-reader/run-screen-reader.mjs @@ -0,0 +1,29 @@ +// Native screen-reader tests require macOS or Windows. +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const args = process.argv.slice(2); +const reader = { darwin: "voiceover", win32: "nvda" }[process.platform]; +if (!reader) { + throw new Error("Screen reader tests require macOS or Windows."); +} +const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL("../node_modules/vite-plus/bin/vp", import.meta.url)), + "test", + "--config", + "screen-reader/vite.config.screen-reader.ts", + "--run", + ...args, + ], + { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { ...process.env, BLOCKNOTE_SCREEN_READER: reader }, + stdio: "inherit", + }, +); +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1); diff --git a/tests/screen-reader/screenReader.ts b/tests/screen-reader/screenReader.ts new file mode 100644 index 0000000000..674344aede --- /dev/null +++ b/tests/screen-reader/screenReader.ts @@ -0,0 +1,66 @@ +import { pressKey } from "../src/utils/keyboard.js"; +import type { BrowserCommand } from "vite-plus/test/node"; +import type {} from "../src/utils/positionalMouse.js"; + +// Import only on native runs: GuidePup cannot initialize on Linux. +async function reader() { + return (await import("@guidepup/guidepup")).screenReader; +} + +/** + * Starts the native reader, activates the test browser, and focuses the supplied + * selector in the test iframe. Synchronizes VoiceOver's cursor with keyboard + * focus and clears startup speech so the transcript contains only the scenario. + * Available only in the native screen-reader configuration; render first. + * + * @example + * await commands.startReader(page.getByRole("textbox").selector); + */ +export const startReader: BrowserCommand<[selector: string]> = async ( + context, + selector, +) => { + const guidepup = await import("@guidepup/guidepup"); + await guidepup.screenReader.start(); + if (process.platform === "darwin") { + await guidepup.macOSActivate("Playwright"); + } else { + await guidepup.windowsActivate("chrome.exe", "Google Chrome for Testing"); + } + await context.page.bringToFront(); + const frame = await context.frame(); + await frame.locator(selector).focus(); + if (process.platform === "darwin") { + await guidepup.voiceOver.perform( + guidepup.voiceOver.keyboardCommands.moveCursorToKeyboardFocus, + ); + } + await guidepup.screenReader.clearSpokenPhraseLog(); +}; + +/** + * Captures native speech while executing the shared keyboard command, including + * its focus-reconciliation wait. Registered as `commands.pressKey` by the + * native screen-reader configuration, so navigation can be shared with browser + * tests without a separate keyboard helper. + */ +export const screenReaderPressKey: BrowserCommand<[key: string]> = async ( + context, + key, +) => { + await (await reader()).capture(() => pressKey(context, key)); +}; + +/** + * Returns the running native reader's spoken phrase log. Assert that it is + * nonempty before comparing it with a Vitest snapshot in the consuming test. + * Reading the log does not stop the reader; cleanup belongs in `finally`. + */ +export const readTranscript: BrowserCommand<[]> = async () => + (await reader()).spokenPhraseLog(); +/** + * Stops the native reader. Call in an `afterEach` hook's `finally` block so + * assertion or transcript failures do not leave the reader running. + */ +export const stopReader: BrowserCommand<[]> = async () => + (await reader()).stop(); diff --git a/tests/screen-reader/vite.config.screen-reader.ts b/tests/screen-reader/vite.config.screen-reader.ts new file mode 100644 index 0000000000..7a8bc32f59 --- /dev/null +++ b/tests/screen-reader/vite.config.screen-reader.ts @@ -0,0 +1,97 @@ +import { + readAriaSnapshot, + readOutline, + checkFocusOutline, + readSelection, + checkAxe, +} from "../src/utils/accessibility.js"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; +import { playwright } from "vite-plus/test/browser/providers/playwright"; +import { + startReader, + screenReaderPressKey, + readTranscript, + stopReader, +} from "./screenReader.js"; + +const reader = process.env.BLOCKNOTE_SCREEN_READER; +if (reader !== "voiceover" && reader !== "nvda") { + throw new Error("Unknown screen reader"); +} +if ( + (reader === "voiceover" && process.platform !== "darwin") || + (reader === "nvda" && process.platform !== "win32") +) { + throw new Error("VoiceOver requires macOS; NVDA requires Windows."); +} + +export default defineConfig({ + root: fileURLToPath(new URL("../", import.meta.url)), + publicDir: fileURLToPath(new URL("../public", import.meta.url)), + resolve: { + alias: { + "@root": fileURLToPath(new URL("../../", import.meta.url)), + "@blocknote/core": fileURLToPath( + new URL("../../packages/core/src", import.meta.url), + ), + "@blocknote/react": fileURLToPath( + new URL("../../packages/react/src", import.meta.url), + ), + "@blocknote/mantine": fileURLToPath( + new URL("../../packages/mantine/src", import.meta.url), + ), + }, + dedupe: ["react", "react-dom", "vitest-browser-react"], + }, + test: { + name: "screen-reader", + setupFiles: [ + fileURLToPath(new URL("../vitestSetup.browser.ts", import.meta.url)), + ], + include: ["../packages/*/src/**/*.screen-reader.test.ts"], + testTimeout: 300_000, + hookTimeout: 30_000, + fileParallelism: false, + retry: 0, + // Missing baselines fail unless explicitly generated with --update. + update: false, + reporters: [ + "verbose", + ["html", { outputDir: `playwright-report/screen-reader/${reader}` }], + ], + attachmentsDir: `.vitest/attachments/screen-reader/${reader}`, + resolveSnapshotPath: (path, extension) => + `${path}-snapshots/screen-reader/${reader}${extension}`, + browser: { + enabled: true, + headless: false, + screenshotFailures: false, + locators: { testIdAttribute: "data-id" }, + provider: playwright({ + contextOptions: { + viewport: { width: 1280, height: 900 }, + locale: "en-US", + colorScheme: "light", + }, + }), + instances: [ + { + browser: reader === "voiceover" ? "webkit" : "chromium", + name: reader, + }, + ], + commands: { + readAriaSnapshot, + readOutline, + checkFocusOutline, + readSelection, + checkAxe, + startReader, + pressKey: screenReaderPressKey, + readTranscript, + stopReader, + }, + }, + }, +}); diff --git a/tests/src/end-to-end/form/popoverSubmit.test.tsx b/tests/src/end-to-end/form/popoverSubmit.test.tsx index e70049e9b1..b13e55d5d2 100644 --- a/tests/src/end-to-end/form/popoverSubmit.test.tsx +++ b/tests/src/end-to-end/form/popoverSubmit.test.tsx @@ -3,15 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { browserName, commands, userEvent } from "../../utils/context.js"; -import type { ImeCompositionCommand } from "../../utils/imeComposition.js"; import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; -const browserCommands = commands as typeof commands & { - imeComposition: ImeCompositionCommand; -}; - /** * The toolbar popovers commit through their form's `submit` event, because a * mobile IME's action key fires that and no key event at all. @@ -170,7 +165,7 @@ describe("Submitting a toolbar popover with Enter", () => { )) as HTMLInputElement; await userEvent.click(input); - await browserCommands.imeComposition([ + await commands.imeComposition([ { type: "setComposition", text: "example.co" }, { type: "commit", text: "example.com" }, ]); diff --git a/tests/src/end-to-end/mobile/androidEnter.test.tsx b/tests/src/end-to-end/mobile/androidEnter.test.tsx index 49954a8447..29a6a0058f 100644 --- a/tests/src/end-to-end/mobile/androidEnter.test.tsx +++ b/tests/src/end-to-end/mobile/androidEnter.test.tsx @@ -8,11 +8,6 @@ import { EDITOR_SELECTOR, } from "../../utils/const.js"; import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; -import type { ImeCompositionCommand } from "../../utils/imeComposition.js"; - -const browserCommands = commands as typeof commands & { - imeComposition: ImeCompositionCommand; -}; // Runs in the "android" and "ios" browser instances (mobile UA + touch // emulation at context level, see vite.config.browser.ts), which make @@ -90,7 +85,7 @@ describe("Enter on Android", () => { BLOCK_CONTAINER_SELECTOR, ).length; - await browserCommands.imeComposition([{ type: "commit", text: "\n" }]); + await commands.imeComposition([{ type: "commit", text: "\n" }]); await vi.waitFor(() => { const blocks = document.querySelectorAll( diff --git a/tests/src/end-to-end/platform/compositionSubmit.test.tsx b/tests/src/end-to-end/platform/compositionSubmit.test.tsx index 6867a93792..0cfa674ff2 100644 --- a/tests/src/end-to-end/platform/compositionSubmit.test.tsx +++ b/tests/src/end-to-end/platform/compositionSubmit.test.tsx @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "vite-plus/test"; import { browserName, commands, userEvent } from "../../utils/context.js"; -import type { ImeCompositionCommand } from "../../utils/imeComposition.js"; /** * Why the popover forms need no composition guard. @@ -22,10 +21,6 @@ import type { ImeCompositionCommand } from "../../utils/imeComposition.js"; * triggers, or a Gboard-style single-press commit-and-submit gets swallowed. */ -const browserCommands = commands as typeof commands & { - imeComposition: ImeCompositionCommand; -}; - // `Input.imeSetComposition` is CDP-only. Firefox and WebKit have no equivalent // in their automation protocols, so real composition state can't be entered // there at all — the behaviour is chromium-verified only. @@ -76,7 +71,7 @@ describeIme("IME composition and form submission", () => { const { input, submits, compositions } = buildForm(); input.focus(); - await browserCommands.imeComposition([ + await commands.imeComposition([ { type: "setComposition", text: "にほん" }, { type: "commit", text: "日本" }, ]); @@ -95,7 +90,7 @@ describeIme("IME composition and form submission", () => { const { input, submits } = buildForm(); input.focus(); - await browserCommands.imeComposition([ + await commands.imeComposition([ { type: "setComposition", text: "にほん" }, { type: "commit", text: "日本" }, ]); diff --git a/tests/src/utils/accessibility.ts b/tests/src/utils/accessibility.ts new file mode 100644 index 0000000000..25defa55e3 --- /dev/null +++ b/tests/src/utils/accessibility.ts @@ -0,0 +1,153 @@ +import axe from "axe-core"; +import { expect } from "@playwright/test"; +import type { BrowserCommand } from "vite-plus/test/node"; + +export type Outline = { width: number; style: string; color: string }; +export type EditorSelection = { + tableCell: { row: number; column: number } | null; + blockId: string | null; + offset: number; + text: string; +}; + +declare global { + interface Window { + axe: typeof axe; + } +} + +/** + * Reads computed outline width (CSS pixels), style, and color in the test iframe. + * Pass a browser locator's `selector`, not its DOM element. Capture this before + * focusing the target to compare against its focused appearance. + * + * @example + * const unfocusedOutline = await commands.readOutline(button.selector); + */ +export const readOutline: BrowserCommand<[selector: string], Outline> = async ( + context, + selector, +) => + (await context.frame()).locator(selector).evaluate((element) => { + const style = getComputedStyle(element); + return { + width: Number.parseFloat(style.outlineWidth), + style: style.outlineStyle, + color: style.outlineColor, + }; + }); + +/** + * Waits for the target to match `:focus-visible` and have a nonzero outline + * whose style is neither `none` nor `hidden` and whose color is not transparent. + * When supplied, the outline must also differ from `unfocusedOutline`. + * Omit that baseline for autofocused inputs that cannot be observed unfocused. + * These checks cover CSS outline properties, not contrast or visual occlusion. + * + * @example + * await commands.checkFocusOutline(button.selector, unfocusedOutline); + */ +export const checkFocusOutline: BrowserCommand< + [selector: string, unfocusedOutline?: Outline], + void +> = async (context, selector, unfocusedOutline) => { + const target = (await context.frame()).locator(selector); + await expect + .poll(() => target.evaluate((element) => element.matches(":focus-visible"))) + .toBe(true); + // Autofocused inputs cannot be observed unfocused; compare when available. + if (unfocusedOutline) { + await expect + .poll(async () => await readOutline(context, selector)) + .not.toEqual(unfocusedOutline); + } + await expect + .poll(async () => (await readOutline(context, selector)).width) + .toBeGreaterThan(0); + await expect + .poll(async () => (await readOutline(context, selector)).style) + .not.toMatch(/^(none|hidden)$/); + await expect + .poll(async () => (await readOutline(context, selector)).color) + .not.toMatch(/^(transparent|rgba\(0, 0, 0, 0\))$/); +}; + +/** + * Reads the DOM selection in the test iframe for keyboard-navigation assertions. + * Block ID and zero-based table coordinates refer to the selection's focus end; + * offset is relative to its focus node. Text is the complete selected text. + * Without a selection, returns null locations, zero offset, and empty text. + * + * @example + * expect((await commands.readSelection()).blockId).toBe("end"); + */ +export const readSelection: BrowserCommand<[], EditorSelection> = async ( + context, +) => + (await context.frame()).evaluate(() => { + const selection = document.getSelection(); + const node = selection?.focusNode; + const element = node instanceof Element ? node : node?.parentElement; + const cell = element?.closest("td, th"); + const row = cell?.parentElement; + return { + tableCell: + cell instanceof HTMLTableCellElement && + row instanceof HTMLTableRowElement + ? { row: row.rowIndex, column: cell.cellIndex } + : null, + blockId: element?.closest("[data-id]")?.getAttribute("data-id") ?? null, + offset: selection?.focusOffset ?? 0, + text: selection?.toString() ?? "", + }; + }); + +/** + * Runs axe in the test iframe and asserts that it finds no violations, including + * the target-size rule. `scope` is an optional CSS selector; otherwise scans the + * document body. Returns incomplete-result messages for manual review. + * Call after the intended UI state is ready and annotate the returned warnings + * in the test, since annotation callbacks cannot cross the command boundary. + * + * @example + * for (const warning of await commands.checkAxe('[role="toolbar"]')) { + * await annotate(warning, "warning"); + * } + */ +export const checkAxe: BrowserCommand<[scope?: string], string[]> = async ( + context, + scope, +) => { + const frame = await context.frame(); + await frame.evaluate(axe.source); + const results = await frame.evaluate( + (scope) => + window.axe.run(scope ? { include: [scope] } : document.body, { + rules: { "target-size": { enabled: true } }, + }), + scope, + ); + expect(results.violations, "axe violations").toEqual([]); + return results.incomplete.flatMap((result) => + result.nodes.map( + (node) => + `axe manual review: ${result.id} — ${result.help}\nTarget: ${JSON.stringify(node.target)}\n${node.failureSummary ?? ""}\n${result.helpUrl}`, + ), + ); +}; + +/** + * Reads Playwright's semantic accessibility snapshot for a selector in the test + * iframe. Compare the returned string in the test so Vitest owns the named + * snapshot and its update policy. This does not run a native screen reader. + * + * @example + * expect(await commands.readAriaSnapshot(button.selector)).toMatchSnapshot( + * "before activation", + * ); + */ +export const readAriaSnapshot: BrowserCommand< + [selector: string], + string +> = async (context, selector) => + (await context.frame()).locator(selector).ariaSnapshot(); diff --git a/tests/src/utils/context.ts b/tests/src/utils/context.ts index ad25d7157f..7c08bca368 100644 --- a/tests/src/utils/context.ts +++ b/tests/src/utils/context.ts @@ -1,10 +1,38 @@ -import { commands, page, server, userEvent } from "vite-plus/test/browser"; +import type { ImeCompositionCommand } from "./imeComposition.js"; +import type { PositionalMouseCommand } from "./positionalMouse.js"; +import type { Outline, EditorSelection } from "./accessibility.js"; +import { + commands as vitestCommands, + page, + server, + userEvent, +} from "vite-plus/test/browser"; // `vite-plus/test/browser` re-exports `vitest/browser`, which Vitest replaces // with a generated virtual module while running in Browser Mode. Import it from // this single place so the e2e utils + tests share one `userEvent` instance // (it preserves keyboard/pointer state across calls) and one command surface. -export { commands, page, server, userEvent }; +export { page, server, userEvent }; + +// Vite+ does not expose the augmentable BrowserCommands interface. +export const commands = vitestCommands as typeof vitestCommands & { + positionalMouse: PositionalMouseCommand; + imeComposition: ImeCompositionCommand; + restoreTouchEmulation(): Promise; + // Registered only by the native screen-reader configuration. + startReader(selector: string): Promise; + readTranscript(): Promise; + stopReader(): Promise; + pressKey(key: string): Promise; + readAriaSnapshot(selector: string): Promise; + readOutline(selector: string): Promise; + checkFocusOutline( + selector: string, + unfocusedOutline?: Outline, + ): Promise; + readSelection(): Promise; + checkAxe(scope?: string): Promise; +}; /** The browser instance the current test is running in. */ export const browserName = server.browser as "chromium" | "firefox" | "webkit"; @@ -15,3 +43,6 @@ export const browserName = server.browser as "chromium" | "firefox" | "webkit"; */ export const MOD: "Meta" | "Control" = server.platform === "darwin" ? "Meta" : "Control"; + +export const selectToLineEndKey = + server.platform === "darwin" ? "Meta+Shift+ArrowRight" : "Shift+End"; diff --git a/tests/src/utils/keyboard.ts b/tests/src/utils/keyboard.ts new file mode 100644 index 0000000000..e67ee23476 --- /dev/null +++ b/tests/src/utils/keyboard.ts @@ -0,0 +1,24 @@ +import type { BrowserCommand } from "vite-plus/test/node"; + +declare global { + interface Window { + __blockNoteWaitForEditorFocus(): Promise; + } +} + +/** + * Sends a Playwright key press after pending ProseMirror focus/selection + * reconciliation in the test iframe has settled. Requires the shared browser + * setup, which installs the reconciliation wait function. + * Accepts Playwright key names and chords, such as "Tab" or "Shift+ArrowDown". + * The native screen-reader configuration registers a speech-capturing version + * under the same command name. + * + * @example + * await commands.pressKey("ArrowDown"); + */ +export const pressKey: BrowserCommand<[key: string]> = async (context, key) => { + const frame = await context.frame(); + await frame.evaluate(() => window.__blockNoteWaitForEditorFocus()); + await context.page.keyboard.press(key); +}; diff --git a/tests/src/utils/mouse.ts b/tests/src/utils/mouse.ts index 6e110ea8df..9cee7a7608 100644 --- a/tests/src/utils/mouse.ts +++ b/tests/src/utils/mouse.ts @@ -1,24 +1,10 @@ import { commands } from "./context.js"; import { DRAG_HANDLE_SELECTOR } from "./const.js"; import { sleep, waitForSelector } from "./editor.js"; -import type { MouseAction, PositionalMouseCommand } from "./positionalMouse.js"; - -// `positionalMouse` is registered as a browser command in vite.config.browser.ts. -// `import type` above keeps the (Node-only) command module out of the browser bundle. -// -// Custom commands are normally declared by augmenting Vitest's `BrowserCommands` -// interface, but that isn't possible here: it's declared in `vitest/internal/browser`, -// which the context modules only import (never re-export), and `vitest` isn't a -// direct dependency of this project — we go through `vite-plus/test/browser`. -// Augmenting a module that merely re-exports declares an unrelated interface, so -// the command is typed at this single boundary instead, against the signature -// derived from its implementation. -const browserCommands = commands as typeof commands & { - positionalMouse: PositionalMouseCommand; -}; +import type { MouseAction } from "./positionalMouse.js"; function runMouse(actions: MouseAction[]): Promise { - return browserCommands.positionalMouse(...actions); + return commands.positionalMouse(...actions); } /** Bounding rect of an element, resolved from a selector or the element itself. */ diff --git a/tests/src/utils/positionalMouse.ts b/tests/src/utils/positionalMouse.ts index adc3e63ce4..840a088840 100644 --- a/tests/src/utils/positionalMouse.ts +++ b/tests/src/utils/positionalMouse.ts @@ -28,8 +28,8 @@ export type MouseAction = /** * Browser-side signature of the {@link positionalMouse} command below, i.e. what * `BrowserCommand` turns into once Vitest strips the (Node-only) - * `BrowserCommandContext` first parameter. Used by `mouse.ts` to type the - * command on the browser `commands` object — see the note there on why this + * `BrowserCommandContext` first parameter. Used by `context.ts` to type the + * shared browser `commands` object — see the note there on why this * can't be a `declare module` augmentation. */ export type PositionalMouseCommand = ( diff --git a/tests/tsconfig.browser.json b/tests/tsconfig.browser.json new file mode 100644 index 0000000000..3f395e49b7 --- /dev/null +++ b/tests/tsconfig.browser.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node", "vite-plus/client"], + "paths": { + "@root/*": ["../*"], + "@shared/*": ["../shared/*"], + "@blocknote/mantine": ["./node_modules/@blocknote/mantine"], + "@blocknote/react": ["./node_modules/@blocknote/react"], + "react": ["./node_modules/@types/react/index.d.ts"], + "vitest-browser-react": ["./node_modules/vitest-browser-react"], + "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"] + } + }, + "include": [ + "src/utils/**/*.ts", + "src/utils/**/*.tsx", + "screen-reader/**/*.ts", + "vitestSetup.browser.ts", + "../packages/*/src/**/*.browser.test.*", + "../packages/*/src/**/*.screen-reader.test.ts" + ] +} diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index ac2b636bd7..4d18c603d7 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -3,6 +3,14 @@ import * as fs from "fs"; import * as path from "path"; import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import { playwright } from "vite-plus/test/browser/providers/playwright"; +import { + readAriaSnapshot, + readOutline, + checkFocusOutline, + readSelection, + checkAxe, +} from "./src/utils/accessibility.js"; +import { pressKey } from "./src/utils/keyboard.js"; import { positionalMouse } from "./src/utils/positionalMouse.js"; import { imeComposition } from "./src/utils/imeComposition.js"; @@ -20,6 +28,7 @@ const DESKTOP_EXCLUDE = [ ...configDefaults.exclude, ...CI_EXCLUDE, "**/end-to-end/mobile/**", + "../packages/*/src/**/*.screen-reader.test.ts", ]; import { restoreTouchEmulation } from "./src/utils/restoreTouchEmulation.js"; @@ -83,6 +92,7 @@ export default defineConfig( // render unstyled because their utility classes don't exist. plugins: [tailwindcss()], resolve: { + dedupe: ["react", "react-dom", "vitest-browser-react"], // Mount example apps via `@examples///src/App`. The examples // live outside the tests root, but vite-plus already allows serving the // workspace root, so only this runtime alias is needed. The matching @@ -90,6 +100,7 @@ export default defineConfig( // src/examples.d.ts. alias: { ...blockNoteSrcAliases, + "@root": path.resolve(__dirname, ".."), // The shared test-utils package lives at the repo root (not under // packages/), so it isn't picked up by the packages scan above. // All consumers - test code and example apps alike - import it via @@ -107,13 +118,17 @@ export default defineConfig( // Besides the end-to-end tests, this suite also runs the packages' // `.browser.test` files: unit tests for browser-only implementations // (canvas rasterization, diagram rendering), colocated with the code - // they test but needing a real browser. The packages' own (node) - // vitest configs exclude them. + // they test but needing a real browser. Native screen-reader tests use + // their own runner. The packages' node configs exclude browser tests. include: [ "./src/end-to-end/**/*.test.tsx", "../packages/*/src/**/*.browser.test.{ts,tsx}", ], - exclude: [...configDefaults.exclude, ...CI_EXCLUDE], + exclude: [ + ...configDefaults.exclude, + ...CI_EXCLUDE, + "../packages/*/src/**/*.screen-reader.test.ts", + ], setupFiles: ["./vitestSetup.browser.ts"], // Running three browsers concurrently inside one Docker container already @@ -165,7 +180,17 @@ export default defineConfig( // still show in the HTML report (errors + stack traces don't depend // on these shots), so disable them. See `e2e:report` to view. screenshotFailures: false, - commands: { positionalMouse, imeComposition, restoreTouchEmulation }, + commands: { + positionalMouse, + imeComposition, + restoreTouchEmulation, + readAriaSnapshot, + readOutline, + checkFocusOutline, + readSelection, + checkAxe, + pressKey, + }, instances: [ { browser: "chromium", diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts index 99365626fb..fd93e2712c 100644 --- a/tests/vitestSetup.browser.ts +++ b/tests/vitestSetup.browser.ts @@ -1,5 +1,6 @@ +import type {} from "./src/utils/keyboard.js"; import { afterEach, beforeAll, beforeEach } from "vite-plus/test"; -import { commands, page } from "vite-plus/test/browser"; +import { commands, page } from "./src/utils/context.js"; import { ensureTouchEmulation } from "./src/utils/ensureTouchEmulation.js"; @@ -46,7 +47,7 @@ beforeAll(async () => { // transitionend/animationend, and no keyframes rely on fill-mode, so // suppressing motion only removes the timing dependency.) const noMotion = document.createElement("style"); - noMotion.textContent = `*, *::before, *::after { transition: none !important; animation: none !important; }`; + noMotion.textContent = `*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }`; document.head.appendChild(noMotion); }); @@ -61,9 +62,7 @@ beforeAll(async () => { // needs to call `ensureTouchEmulation` itself. beforeEach(async () => { if (/android/i.test(navigator.userAgent)) { - await ( - commands as unknown as { restoreTouchEmulation(): Promise } - ).restoreTouchEmulation(); + await commands.restoreTouchEmulation(); ensureTouchEmulation(); } // Playwright's WebKit emulation sets `(pointer: coarse)` and `ontouchstart` @@ -83,3 +82,37 @@ beforeEach(() => { afterEach(() => { delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; }); + +let pendingEditorFocus = Promise.resolve(); + +async function waitForEditorFocus() { + let pending; + do { + pending = pendingEditorFocus; + await pending; + } while (pending !== pendingEditorFocus); +} + +// Provider commands run in Node and await this function inside the test iframe. +window.__blockNoteWaitForEditorFocus = waitForEditorFocus; + +function onEditorFocus(event: FocusEvent) { + if ( + event.target instanceof HTMLElement && + event.target.matches('.bn-editor[contenteditable="true"]') + ) { + // ProseMirror schedules selection reconciliation 20 ms after focus. + // This bubbling listener runs after its focus handler has scheduled that work. + pendingEditorFocus = new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +beforeEach(() => { + document.addEventListener("focusin", onEditorFocus); +}); + +afterEach(async () => { + document.removeEventListener("focusin", onEditorFocus); + await waitForEditorFocus(); + pendingEditorFocus = Promise.resolve(); +}); diff --git a/tsconfig.json b/tsconfig.json index 4546397b50..8e5af666b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "files": [], "references": [ + { "path": "./tests/tsconfig.browser.json" }, { "path": "./packages/core" }, { "path": "./packages/code-block" }, { "path": "./packages/react" }, diff --git a/vite.config.ts b/vite.config.ts index adbc33b4ef..070e97bfaf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,10 @@ export default defineConfig({ // its tsc step reads `core`'s `.d.ts` files. cache: { scripts: true }, tasks: { + "test:screen-reader": { + command: "node tests/screen-reader/run-screen-reader.mjs", + cache: false, + }, "copy-package-readmes": { command: "node scripts/copy-package-readmes.mjs", // These copies are outputs, not inputs: automatic tracking of the @@ -95,6 +99,92 @@ export default defineConfig({ "import/no-cycle": "off", }, overrides: [ + { + files: ["packages/*/src/**/*.{ts,tsx}"], + excludeFiles: ["**/*.test.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["**/*.test", "**/*.test.*"], + message: "Production code must not import test files.", + }, + ], + }, + ], + }, + }, + { + files: [ + "**/*.browser.test.{ts,tsx}", + "**/*.screen-reader.test.{ts,tsx}", + ], + plugins: ["vitest"], + rules: { + // The plugin enables these defaults, which do not fit our test conventions. + // Assertions can live in shared helpers outside a test callback. + "vitest/expect-expect": "off", + "vitest/no-standalone-expect": "off", + // Browser-specific coverage and documented known failures are intentional. + "vitest/no-conditional-expect": "off", + "vitest/no-conditional-tests": "off", + "vitest/no-disabled-tests": "off", + // Existing mocks do not all provide explicit type parameters. + "vitest/require-mock-type-parameters": "off", + "vitest/no-focused-tests": "error", + "vitest/valid-expect": ["error", { maxArgs: 2 }], + "vitest/require-awaited-expect-poll": "error", + "typescript/no-floating-promises": [ + "error", + { ignoreVoid: false, checkThenables: true }, + ], + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "**/utils/accessibility", + "**/utils/accessibility.*", + "**/utils/keyboard", + "**/utils/keyboard.*", + "**/screen-reader/screenReader", + "**/screen-reader/screenReader.*", + ], + allowTypeImports: true, + message: + "Use the registered commands from tests/src/utils/context.ts instead of importing command implementations.", + }, + { + group: ["timers", "timers/*", "node:timers", "node:timers/*"], + message: + "Wait for observable readiness with expect.element or expect.poll instead of sleeping.", + }, + ], + }, + ], + "no-restricted-properties": [ + "error", + { + property: "getByTestId", + message: + "Use accessible locators such as getByRole with a name or getByLabel in browser tests.", + }, + { + property: "waitForTimeout", + message: + "Wait for observable readiness with expect.element or expect.poll instead of sleeping.", + }, + { + property: "sleep", + message: + "Wait for observable readiness with expect.element or expect.poll instead of sleeping.", + }, + ], + }, + }, { files: [ "**/scripts/**",