BlockNote is adopting Vite Plus (the vp CLI), which bundles Vitest 4.1.5 Browser Mode with a built-in Playwright provider (@voidzero-dev/vite-plus-test). We want the e2e suite (tests/src/end-to-end, 24 files / ~136 tests) to run under Vitest Browser Mode instead of standalone Playwright, so the whole test toolchain is unified under vp test (no separate preview server, no separate Playwright config/runner).
The fundamental shift: today each test navigates to a built preview page (page.goto("http://localhost:3000/basic/testing?hideMenu")), which requires building + serving the playground app on port 3000. Vitest Browser Mode runs the test file inside the browser and serves the test plus everything it imports through its own Vite dev server. So instead of navigating to an example, each test imports that example's App component and mounts it — no preview server at all.
We are also removing the abandoned Playwright component-testing experiment (tests/src/component, the external copy/paste tests are explicitly flagged as not-yet-correct).
Decisions confirmed with the user: full migration (all 24 files); migrate the 42 PNG visual snapshots to Vitest's toMatchScreenshot (regenerate baselines); keep all three browsers (chromium, firefox, webkit) with the existing per-browser skips.
Each example's examples/<group>/<name>/src/App.tsx is a default-exported React component (export default function App()). The playground already loads these dynamically via import.meta.glob (playground/src/main.tsx:22,119-130). We do the same in tests, but with a static import + synchronous render:
import { render } from "vitest-browser-react";
import App from "../../../../examples/01-basic/testing/src/App.js";
beforeEach(() => {
render(<App />);
});window.ProseMirror(used bygetDoc) is set byuseCreateBlockNote(useCreateBlockNote.tsx:30-33), so it works for any mounted example.?hideMenuonly hides the playground shell (playground/src/main.tsx:54,66); mountingAppdirectly means there is no shell, so the param is dropped entirely.- The example→folder mapping (URL slug strips the numeric prefix), to replace every URL constant in tests/src/utils/const.ts:
| Old constant (slug) | Example App to import |
|---|---|
BASE_URL /basic/testing |
examples/01-basic/testing |
SHADCN_URL /basic/shadcn |
examples/01-basic/09-shadcn |
ARIAKIT_URL /basic/ariakit |
examples/01-basic/08-ariakit |
MULTI_COLUMN_URL /basic/multi-column |
examples/01-basic/03-multi-column |
BASIC_BLOCKS_URL /basic/default-blocks |
examples/01-basic/04-default-blocks |
NO_TRAILING_BLOCK_URL /basic/no-trailing-block |
examples/01-basic/17-no-trailing-block |
AI_URL /ai/minimal |
examples/09-ai/01-minimal |
STATIC_URL /backend/rendering-static-documents |
examples/02-backend/04-rendering-static-documents |
BASIC_BLOCKS_STATIC_URL /interoperability/static-html-render |
examples/05-interoperability/10-static-html-render |
CUSTOM_BLOCKS_REACT_URL /custom-schema/react-custom-blocks |
examples/06-custom-schema/react-custom-blocks |
ALERT_BLOCK_URL /custom-schema/alert-block |
examples/06-custom-schema/01-alert-block |
NON_EDITABLE_BLOCK_URL /custom-schema/non-editable-block |
examples/06-custom-schema/08-non-editable-block |
PDF_FILE_BLOCK_URL /custom-schema/pdf-file-block |
examples/06-custom-schema/04-pdf-file-block |
COMMENTS_URL /collaboration/comments-testing |
examples/07-collaboration/09-comments-testing |
CUSTOM_BLOCKS_VANILLA_URL /vanilla-js/react-vanilla-custom-blocks |
examples/vanilla-js/react-vanilla-custom-blocks |
tsconfig note: statically importing
examples/**/App.tsxpulls example sources into the tests'tscbuild task (tests/vite.config.ts:9-17). Validate that the teststsconfiginclude/references cover these (or add an@examples/*path alias). If type friction is excessive, fall back to the playground'simport.meta.glob(..., { import: "default" })pattern in a smallloadExampleApphelper.
vitest-browser-react— providesrender(+ auto-cleanup between tests). Required; Vite Plus bundles the runner + Playwright provider but not a framework render helper. Use a Vitest-4-compatible version (add via the workspacecatalog:likevite-plus).playwright— add explicitly. The provider runsawait import('playwright')and bareplaywrightis not currently resolvable (only@playwright/testis). Pin to the existing1.60.0.- Remove
@playwright/experimental-ct-react.@playwright/testcan also be removed once nothing imports from it (keepplaywrightonly).
1. New browser test project — tests/vite.config.browser.ts:
import { defineConfig, type UserConfig } from "vite-plus";
import { playwright } from "vite-plus/test/browser/providers/playwright";
import { dragAndDropBlock, dragMouse } from "./src/end-to-end/commands"; // see step 3
export default defineConfig(
(conf) =>
({
test: {
name: "e2e",
include: ["./src/end-to-end/**/*.test.ts"],
setupFiles: ["./vitestSetup.browser.ts"],
browser: {
enabled: true,
provider: playwright(), // function call, NOT the string "playwright"
headless: !!process.env.CI,
commands: { dragAndDropBlock, dragMouse },
expect: {
toMatchScreenshot: {
comparatorName: "pixelmatch",
comparatorOptions: {
threshold: 0.2,
allowedMismatchedPixelRatio: 0.01,
},
},
},
instances: [
{ browser: "chromium" },
{ browser: "firefox" },
{ browser: "webkit" },
],
},
// reuse the dev-time resolve.alias for @blocknote/core + @blocknote/react -> src
},
resolve: {
/* same alias block as tests/vite.config.ts */
},
}) as UserConfig,
);2. Register the project in the root vite.config.ts test.projects array (alongside "./tests/vite.config.ts"): add "./tests/vite.config.browser.ts". The existing tests/vite.config.ts jsdom project (unit tests) stays unchanged — browser instances and jsdom cannot share one test block, so they remain separate projects.
3. Custom mouse commands — tests/src/end-to-end/commands/ (run in Node, get the real Playwright page):
Port tests/src/utils/mouse.ts logic verbatim into commands that resolve selectors via frame() (its boundingBox() returns top-level-page coordinates, sidestepping iframe-offset math). Example:
import { defineBrowserCommand } from "vite-plus/test/browser/providers/playwright";
export const dragAndDropBlock = defineBrowserCommand<
[dragSel: string, dropSel: string, dropAbove: boolean]
>(async ({ frame }, dragSel, dropSel, dropAbove) => {
const f = await frame();
const drag = f.locator(dragSel);
const box = (await drag.boundingBox())!;
// hover block -> drag handle appears -> drag handle center -> target left/right edge
// (mirrors dragAndDropBlock in mouse.ts using context.page.mouse.move/down/up)
});Augment the BrowserCommands interface (in vitestSetup.browser.ts or a .d.ts) so server.commands.dragAndDropBlock(...) is typed. Call from tests via import { server } from "vite-plus/test/browser/context".
4. Browser setup file — tests/vitestSetup.browser.ts: sets window.__TEST_OPTIONS per test (replacing the Playwright init-script in tests/src/setup/setupScript.ts) and the command type augmentation. Drop the jsdom-only mocks (ClipboardEvent/DragEvent/matchMedia) — the real browser provides them; those stay in the existing tests/vitestSetup.ts for the unit project.
All helpers currently take page: Page and use the Playwright API. Rewrite them to use the global Vitest browser context (page, userEvent, server from vite-plus/test/browser/context) — they no longer need a page argument. The biggest simplification: the test runs in the browser, so window/document are directly accessible.
| Util | Today (Playwright) | After (Vitest browser) |
|---|---|---|
editor.ts getDoc |
page.evaluateHandle → window.ProseMirror.getJSON() |
(window as any).ProseMirror.getJSON() directly |
editor.ts compareDocToSnapshot |
expect(doc).toMatchSnapshot("x.json") |
expect(docStr).toMatchFileSnapshot(\snapshots/${name}-${server.browser}.json`)` (browser name in filename for per-browser baselines) |
editor.ts focusOnEditor / waitForSelectorInEditor |
page.waitForSelector/click |
await vi.waitFor(() => document.querySelector(".bn-editor")); await userEvent.click(el); await expect.element(...).toBeVisible() |
mouse.ts |
page.mouse.move/down/up, locator.boundingBox() |
thin wrappers calling server.commands.dragAndDropBlock(...) / dragMouse(...); coord math moves into the command |
copypaste.ts copyPaste |
page.keyboard Ctrl+C/V |
userEvent.copy() / userEvent.paste() (or userEvent.keyboard("{Control>}c{/Control}")) |
copypaste.ts copyPasteAllExternal(os) |
passed os |
server.platform (external tests removed anyway — see below) |
slashmenu.ts / draghandle.ts / emojipicker.ts |
page.keyboard.press, page.waitForSelector |
userEvent.keyboard, vi.waitFor / expect.element(...) |
const.ts |
URL constants + selectors | drop URL constants; keep CSS selector constants + TYPE_DELAY |
pure helpers (removeAttFromDoc, removeClassesFromHTML, removeMetaFromHTML) |
— | keep as-is |
Standard transformation per *.test.ts:
- Imports: replace
import { expect } from "@playwright/test"+import { test } from "../../setup/setupScript.js"withimport { test, expect, beforeEach, vi } from "vite-plus/test",import { userEvent, page, server } from "vite-plus/test/browser/context",import { render } from "vitest-browser-react", and the exampleAppimport. - Setup:
test.beforeEach(async ({ page }) => { await page.goto(URL) })→beforeEach(() => { render(<App />); }). Drop the{ page }fixture from every test signature (use the globalpage). - API translation:
page.locator(css)/ queries →document.querySelector(css)(in-browser) orpage.elementLocator(el);userEvent/expect.elementaccept rawElement.page.keyboard.insertText/type/press→userEvent.keyboard(...)(testing-library syntax:{Enter},{Shift>}{ArrowUp}{/Shift}, etc.).element.boundingBox()→el.getBoundingClientRect().page.waitForSelector/locator.waitFor→vi.waitFor(...)orawait expect.element(locator).toBeVisible().page.evaluate(fn)→ run the code directly (already in browser).expect(await el.textContent()).toBe(x)→await expect.element(page.elementLocator(el)).toHaveTextContent(x).- file upload (
page.on("filechooser"), images tests) →userEvent.upload(inputEl, file). test.use({ viewport })(ai.test.ts) →page.viewport(w, h)inbeforeEach.
- Per-browser skips:
test.skip(browserName === "firefox", ...)→test.skipIf(server.browser === "firefox")(...). Note copy/paste +cdp()are Chromium-only, matching existing skips.
- JSON doc snapshots (82 uses of
compareDocToSnapshot): →toMatchFileSnapshot, embeddingserver.browserin the filename for per-browser baselines. Regenerate with the Vitest update flag. - PNG visual snapshots (42 uses across 11 files):
expect(await page.screenshot()).toMatchSnapshot("x.png")→await expect.element(locator).toMatchScreenshot("x"). Vitest auto-appends-${browserName}-${platform}to baseline filenames (default dir__screenshots__/<testFileName>/). All baselines must be regenerated (Vitest screenshots differ from the old Playwright/Docker PNGs). - Regeneration must run in Docker for cross-platform/CI consistency, mirroring the current
test:updateSnapsDocker flow (tests/package.json) — replace it with a Docker invocation ofvp test --project e2e -u(headless). Old*.test.ts-snapshots/dirs are replaced by the new Vitest snapshot locations.
- Delete
tests/src/component/entirely (incl.snapshots/) — the half-baked Playwright CT experiment (external tests carry an explicit "not the output we want" TODO). - Delete
tests/playwright.config.ts,tests/playwright-ct.config.ts,tests/src/setup/setupScript.ts,tests/src/setup/setupScriptComponent.ts. tests/package.jsonscripts: removeplaywright,test-ct,test-ct:updateSnaps; reworktest:updateSnapsto the Dockervp test -uflow. Thetestscript (vp test --run) now runs jsdom unit + browser e2e projects.- Root package.json: drop the
e2e/e2e:updateSnapsconcurrently "vp run start" + wait-on :3000 + playwrightorchestration — replace withvp test --project e2e(no preview server). Keep aninstall-playwrightstep (playwright install --with-deps).
- Add deps (
vitest-browser-react,playwright); remove CT dep. - Add
tests/vite.config.browser.ts+ register in roottest.projects; addvitestSetup.browser.ts. - Implement the
commands/mouse commands + type augmentation. - Rewrite
tests/src/utils/*to the browser context API. - Convert one representative file first (
basics/basics.test.ts, thendraghandle/draghandle.test.tsto exercise drag + visual snapshots) to validate the whole chain end-to-end before batch-converting. - Convert remaining files; replace URL constants with example imports.
- Delete component tests + Playwright configs/setup; clean scripts.
- Regenerate all snapshots (JSON + screenshots) in Docker; commit baselines.
cd tests && vp test --project e2e— runs the full e2e suite headless across chromium/firefox/webkit, with no preview server running. Confirms mounting + interactions + commands work.vp testfrom repo root — both the jsdom unit project and the browser e2e project run and pass.- Spot-check a visual test (
theming/colors/slashmenu) produces a__screenshots__/...-<browser>-<platform>.pngbaseline and re-runs green. - Spot-check a drag test (
draghandle) to confirm thedragAndDropBlockcommand drives the real Playwright mouse correctly through the test iframe. - Confirm the AI test's
window.__TEST_OPTIONS.mockIDis set by the browser setup file before render. - Grep confirms zero remaining imports from
@playwright/test,@playwright/experimental-ct-react, or../../setup/setupScript.
Done & statically verified (tsc --noEmit 0 errors, vp lint src 0 errors):
- Browser project
tests/vite.config.browser.ts(providerplaywright({ launchOptions: { args: ["--no-sandbox", "--disable-setuid-sandbox"] } }), 3 instances,optimizeDeps.exclude: ["fsevents"]), registered in rootvite.config.tstest.projects. tests/vitestSetup.browser.ts(seedswindow.__TEST_OPTIONS).tests/src/end-to-end/commands/playwrightMouse.ts— the low-level mouse command (resolves the iframe offset via Playwrightframe()).tests/src/utils/context.ts— adapter for this vite-plus build: the browser-context runtime exportscreateUserEvent(factory),page,cdp,locators,utils— not theuserEvent/server/commandsits.d.tsadvertises. Socontext.tsbuildsuserEvent = createUserEvent(), derivesMOD/browserNamefromnavigator, and triggers commands viawindow.__vitest_browser_runner__.commands.triggerCommand.- All other utils rewritten (
editor,mouse,copypaste,slashmenu,emojipicker,draghandle,keyboard,render,const). - All 24 e2e files converted to
.test.tsxmounting exampleApps. Component tests, both Playwright configs,src/setup/*, and orphaned*.test.ts-snapshots/dirs removed. Scripts updated:tests→test:e2e/test:e2e:updateSnaps; roote2e→vp run -r build && cd tests && vp test -c vite.config.browser.ts --run. - Shims:
tests/src/examples.d.ts(@examples/*→ React component) andtests/src/vitest-browser.d.ts(declares the runtimecreateUserEvent).
NOT validated at runtime here: This assistant sandbox cannot complete a Vitest Browser Mode run — even a trivial test fails with "Browser connection was closed while running tests / Was the page closed unexpectedly?" (the headless page dies before the runner initializes). This is environmental; browser tests run fine on the user's machine/CI. Validate with cd tests && pnpm test:e2e (or pnpm e2e from root), then pnpm test:e2e:updateSnaps to generate baselines (do snapshot generation in Docker for cross-platform/CI parity).
Gotchas worth knowing when validating:
- Test discovery only matches
**/*.test.tsx(recursive glob). Files must live in a subdirectory (not directly underend-to-end/) and must not be underscore-prefixed. - Cold dep-optimization is slow on first run (heavy
@mantinegraph); it caches afterward. Don't mistake a slow first run for a hang. - Example apps load from built
dist(no source aliases) — that's whye2ebuilds first.
Known limitations / TODOs left in code (search TODO(migration) / NOTE:):
theming/theming.test.tsx— the oldtest.use({ colorScheme: "dark" })has no per-test equivalent in this Vitest browser build; the dark-theme screenshot won't render dark until colorScheme emulation is wired up.static/static.test.tsx—matchPageScreenshotdoesn't expose Playwright'smask/maxDiffPixels, so the original media-masking + 200px tolerance aren't applied; may be flaky until the helper is extended.images/images.test.tsxupload test istest.skip(file-path upload →userEvent.uploadwith a placeholderFile).comments/comments.test.tsxpopup assertion reimplemented viavi.spyOn(window, "open").@playwright/testleft as a devDependency (harmless; onlyplaywrightis required by the provider) — can be removed.