is not valid HTML, so this is compared raw.
expect(container.innerHTML).toBe(
'
',
);
@@ -2342,6 +2361,7 @@ describe('LexicalEditor tests', () => {
elementNode1.append(elementNode3);
});
+ // A
inside a
is not valid HTML, so this is compared raw.
expect(container.innerHTML).toBe(
'
',
);
@@ -3770,7 +3790,8 @@ describe('LexicalEditor tests', () => {
await editor.update(() => {
$getRoot().append($createParagraphNode().append($createTextNode('foo')));
editor.read(() => {});
- expect(editor.getRootElement()?.innerHTML).toBe(
+ expectHtmlToBeEqual(
+ editor.getRootElement()?.innerHTML ?? '',
'
foo
',
);
editor.update(() => {
@@ -3779,7 +3800,8 @@ describe('LexicalEditor tests', () => {
);
});
});
- expect(editor.getRootElement()?.innerHTML).toBe(
+ expectHtmlToBeEqual(
+ editor.getRootElement()?.innerHTML ?? '',
'
foo
bar
',
);
});
@@ -3796,7 +3818,8 @@ describe('LexicalEditor tests', () => {
);
});
- expect(container.firstElementChild?.innerHTML).toBe(
+ expectHtmlToBeEqual(
+ container.firstElementChild?.innerHTML ?? '',
'
Hello
',
);
});
diff --git a/packages/lexical/src/__tests__/utils/index.tsx b/packages/lexical/src/__tests__/utils/index.tsx
index 4306e0d1a3a..8cc86aa46a7 100644
--- a/packages/lexical/src/__tests__/utils/index.tsx
+++ b/packages/lexical/src/__tests__/utils/index.tsx
@@ -603,9 +603,11 @@ export function polyfillContentEditable() {
* first / last edge.
*/
export const DECORATOR_BOUNDARY_ANCHOR_HTML =
- '
![]()
';
+ '
![]()
';
/**
* Assert that a node's generated exporters write exactly what the schema-driven
diff --git a/vitest.config.mts b/vitest.config.mts
index 6ca6b287c4f..272f7a914d3 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -6,6 +6,8 @@
*
*/
+import type {Plugin} from 'vite';
+
import react from '@vitejs/plugin-react';
import {playwright} from '@vitest/browser-playwright';
import * as fs from 'node:fs';
@@ -13,6 +15,28 @@ import * as path from 'node:path';
import {fileURLToPath} from 'node:url';
import {configDefaults, defineConfig} from 'vitest/config';
+// @vitest/browser adds @vitest/mocker's interceptor plugin with
+// `registerWebSocketEvents: false`, which still defines a configureServer
+// hook that does nothing, so Vite warns on every browser run that the hook is
+// ignored. Vitest installs its own logger, so drop the warning from the
+// resolved one.
+const IGNORED_INTERCEPTOR_WARNING =
+ 'Plugin "vitest:mocks:interceptor" defines Vite-specific hooks (configureServer)';
+function ignoreInterceptorWarning(): Plugin {
+ return {
+ configResolved(config) {
+ const {logger} = config;
+ const {warnOnce} = logger;
+ logger.warnOnce = (message, options) => {
+ if (!message.startsWith(IGNORED_INTERCEPTOR_WARNING)) {
+ warnOnce(message, options);
+ }
+ };
+ },
+ name: 'lexical:ignore-interceptor-warning',
+ };
+}
+
// Resolve monorepo imports to TypeScript source from the test tsconfig's
// `paths`. This includes the cross-package and deep `*/src/__tests__/utils`
// aliases that the unit tests rely on but that the lean root tsconfig (which
@@ -126,11 +150,170 @@ export default defineConfig({
'react-dom/client',
],
},
- plugins: [react()],
+ plugins: [react(), ignoreInterceptorWarning()],
test: {
api: {port: 8315},
browser: {
commands: {
+ // Drive a real mouse drag through `points` (relative to the
+ // top-left of `selector`), so the browser's own hit testing
+ // and drag-extension logic decide the selection. Synthetic
+ // Selection.setBaseAndExtent/extend calls do not model that.
+ // A clickCount above 1 makes the press that drags the last
+ // click of a double or triple click.
+ mouseDrag: async (
+ {frame, page},
+ selector: string,
+ points: [x: number, y: number][],
+ {clickCount = 1}: {clickCount?: number} = {},
+ ) => {
+ const testFrame = await frame();
+ const target = testFrame.locator(selector);
+ const [first, ...rest] = points;
+ await target.hover({
+ force: true,
+ position: {x: first[0], y: first[1]},
+ });
+ // hover() looks the element up, scrolls it into view and
+ // checks it on every call. The element stays put during a
+ // drag, so move the mouse to page coordinates from its box.
+ const box = await target.boundingBox();
+ if (box === null) {
+ throw new Error(
+ `mouseDrag target ${selector} is not visible`,
+ );
+ }
+ // A person's mouse events arrive far enough apart that the
+ // browser and the editor have handled each one, and the
+ // selection changes it caused, before the next. Count the
+ // pointer events the frame has dispatched, and remember the
+ // selection each selectionchange reported, so that each step
+ // can wait for exactly that instead of for a while.
+ await testFrame.evaluate(() => {
+ const getPoints = () => {
+ const selection = document.getSelection();
+ return selection === null
+ ? []
+ : [
+ selection.anchorNode,
+ selection.anchorOffset,
+ selection.focusNode,
+ selection.focusOffset,
+ ];
+ };
+ const state = {events: 0, notified: getPoints()};
+ const onPointer = () => {
+ state.events++;
+ };
+ const onSelectionChange = () => {
+ state.notified = getPoints();
+ };
+ const types = ['pointerdown', 'pointermove', 'pointerup'];
+ for (const type of types) {
+ window.addEventListener(type, onPointer, true);
+ }
+ window.addEventListener(
+ 'selectionchange',
+ onSelectionChange,
+ true,
+ );
+ Object.assign(window, {
+ __mouseDrag: {
+ dispose() {
+ for (const type of types) {
+ window.removeEventListener(type, onPointer, true);
+ }
+ window.removeEventListener(
+ 'selectionchange',
+ onSelectionChange,
+ true,
+ );
+ },
+ getPoints,
+ state,
+ },
+ });
+ });
+ let events = 0;
+ // Wait until the frame has dispatched the step's pointer
+ // event, then until the tasks it queued have run (the editor
+ // adjusts a drag's selection in one) and no selectionchange
+ // is still to be dispatched and handled.
+ const settle = () =>
+ testFrame.evaluate(async expected => {
+ const {getPoints, state} = (
+ window as unknown as {
+ __mouseDrag: {
+ getPoints: () => unknown[];
+ state: {events: number; notified: unknown[]};
+ };
+ }
+ ).__mouseDrag;
+ const nextTask = () =>
+ new Promise
(resolve => setTimeout(resolve, 0));
+ const isPending = () =>
+ getPoints().some(
+ (value, i) => value !== state.notified[i],
+ );
+ // Never hang a test on an event the browser coalesced
+ // or a selection that keeps changing.
+ const deadline = performance.now() + 1000;
+ do {
+ await nextTask();
+ } while (
+ (state.events < expected || isPending()) &&
+ performance.now() < deadline
+ );
+ }, ++events);
+ for (let count = 1; count < clickCount; count++) {
+ await page.mouse.down({clickCount: count});
+ await settle();
+ await page.mouse.up({clickCount: count});
+ await settle();
+ }
+ await page.mouse.down({clickCount});
+ await settle();
+ // Chromium collapses the selection at the pointer on the
+ // first drag move after a press that placed a caret, and
+ // extends it from there, so a drag's anchor is where it first
+ // moved. A hand starts a drag with a move of a pixel or two,
+ // so make the first move one; after that, move in steps of
+ // at most STEP pixels, half the width of the tests' inline
+ // decorators, so the pointer stops over each one it crosses.
+ const STEP = 20;
+ let [lastX, lastY] = first;
+ if (rest.length > 0) {
+ const [x, y] = rest[0];
+ const distance = Math.hypot(x - lastX, y - lastY);
+ if (distance > 2) {
+ lastX += ((x - lastX) * 2) / distance;
+ lastY += ((y - lastY) * 2) / distance;
+ await page.mouse.move(box.x + lastX, box.y + lastY);
+ await settle();
+ }
+ }
+ for (const [x, y] of rest) {
+ const steps = Math.max(
+ 1,
+ Math.ceil(Math.hypot(x - lastX, y - lastY) / STEP),
+ );
+ for (let i = 1; i <= steps; i++) {
+ await page.mouse.move(
+ box.x + lastX + ((x - lastX) * i) / steps,
+ box.y + lastY + ((y - lastY) * i) / steps,
+ );
+ await settle();
+ }
+ [lastX, lastY] = [x, y];
+ }
+ await page.mouse.up();
+ await settle();
+ await testFrame.evaluate(() =>
+ (
+ window as unknown as {__mouseDrag: {dispose: () => void}}
+ ).__mouseDrag.dispose(),
+ );
+ },
// Vitest's keyboard descriptor parser splits non-BMP text into
// UTF-16 code units. Native Playwright typing preserves code
// points, matching the E2E driver's input behavior. Bound each