mirror of
https://github.com/facebook/lexical.git
synced 2025-05-20 00:26:38 +08:00

* Add attributes formatter (Firefox and Chrome output different order) * Add html formatter to trigger pritter in VC / precommit hook * Add html format trigger to tests
501 lines
13 KiB
JavaScript
501 lines
13 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow strict
|
|
*/
|
|
|
|
import {expect, test as base} from '@playwright/test';
|
|
import prettier from 'prettier';
|
|
import {URLSearchParams} from 'url';
|
|
import {v4 as uuidv4} from 'uuid';
|
|
|
|
import {selectAll} from '../keyboardShortcuts/index.mjs';
|
|
|
|
export const E2E_PORT = process.env.E2E_PORT || 3000;
|
|
export const E2E_BROWSER = process.env.E2E_BROWSER;
|
|
export const IS_MAC = process.platform === 'darwin';
|
|
export const IS_WINDOWS = process.platform === 'win32';
|
|
export const IS_LINUX = !IS_MAC && !IS_WINDOWS;
|
|
export const IS_COLLAB =
|
|
process.env.E2E_EDITOR_MODE === 'rich-text-with-collab';
|
|
const IS_RICH_TEXT = process.env.E2E_EDITOR_MODE !== 'plain-text';
|
|
const IS_PLAIN_TEXT = process.env.E2E_EDITOR_MODE === 'plain-text';
|
|
|
|
export async function initialize({
|
|
page,
|
|
isCollab,
|
|
isCharLimit,
|
|
isCharLimitUtf8,
|
|
}) {
|
|
const appSettings = {};
|
|
appSettings.isRichText = IS_RICH_TEXT;
|
|
appSettings.disableBeforeInput =
|
|
process.env.E2E_EVENTS_MODE === 'legacy-events';
|
|
if (isCollab) {
|
|
appSettings.isCollab = isCollab;
|
|
appSettings.collabId = uuidv4();
|
|
}
|
|
if (appSettings.showNestedEditorTreeView === undefined) {
|
|
appSettings.showNestedEditorTreeView = true;
|
|
}
|
|
appSettings.isCharLimit = !!isCharLimit;
|
|
appSettings.isCharLimitUtf8 = !!isCharLimitUtf8;
|
|
|
|
const urlParams = appSettingsToURLParams(appSettings);
|
|
const url = `http://localhost:${E2E_PORT}/${
|
|
isCollab ? 'split/' : ''
|
|
}?${urlParams.toString()}`;
|
|
await page.goto(url);
|
|
}
|
|
|
|
export const test = base.extend({
|
|
isCharLimit: false,
|
|
isCharLimitUtf8: false,
|
|
isCollab: IS_COLLAB,
|
|
isPlainText: IS_PLAIN_TEXT,
|
|
isRichText: IS_RICH_TEXT,
|
|
});
|
|
|
|
export {expect} from '@playwright/test';
|
|
|
|
function appSettingsToURLParams(appSettings) {
|
|
const params = new URLSearchParams();
|
|
Object.entries(appSettings).forEach(([setting, value]) => {
|
|
params.append(setting, value);
|
|
});
|
|
return params;
|
|
}
|
|
|
|
export async function repeat(times, cb) {
|
|
for (let i = 0; i < times; i++) {
|
|
await cb();
|
|
}
|
|
}
|
|
|
|
export async function clickSelectors(page, selectors) {
|
|
for (let i = 0; i < selectors.length; i++) {
|
|
await waitForSelector(page, selectors[i]);
|
|
await click(page, selectors[i]);
|
|
}
|
|
}
|
|
|
|
async function assertHTMLOnPageOrFrame(
|
|
page,
|
|
pageOrFrame,
|
|
expectedHtml,
|
|
ignoreClasses,
|
|
ignoreInlineStyles,
|
|
) {
|
|
const actualHtml = await pageOrFrame.innerHTML('div[contenteditable="true"]');
|
|
const actual = prettifyHTML(actualHtml, {
|
|
ignoreClasses,
|
|
ignoreInlineStyles,
|
|
});
|
|
const expected = prettifyHTML(expectedHtml.replace(/\n/gm, ''), {
|
|
ignoreClasses,
|
|
ignoreInlineStyles,
|
|
});
|
|
expect(actual).toEqual(expected);
|
|
}
|
|
|
|
export async function assertHTML(
|
|
page,
|
|
expectedHtml,
|
|
{
|
|
ignoreSecondFrame = false,
|
|
ignoreClasses = false,
|
|
ignoreInlineStyles = false,
|
|
} = {},
|
|
) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
await assertHTMLOnPageOrFrame(
|
|
page,
|
|
leftFrame,
|
|
expectedHtml,
|
|
ignoreClasses,
|
|
ignoreInlineStyles,
|
|
);
|
|
if (!ignoreSecondFrame) {
|
|
let attempts = 0;
|
|
while (attempts < 4) {
|
|
const rightFrame = await page.frame('right');
|
|
let failed = false;
|
|
try {
|
|
await assertHTMLOnPageOrFrame(
|
|
page,
|
|
rightFrame,
|
|
expectedHtml,
|
|
ignoreClasses,
|
|
ignoreInlineStyles,
|
|
);
|
|
} catch (e) {
|
|
if (attempts === 5) {
|
|
throw e;
|
|
}
|
|
failed = true;
|
|
}
|
|
if (!failed) {
|
|
break;
|
|
}
|
|
attempts++;
|
|
await sleep(500);
|
|
}
|
|
}
|
|
} else {
|
|
await assertHTMLOnPageOrFrame(
|
|
page,
|
|
page,
|
|
expectedHtml,
|
|
ignoreClasses,
|
|
ignoreInlineStyles,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function assertSelectionOnPageOrFrame(page, expected) {
|
|
// Assert the selection of the editor matches the snapshot
|
|
const selection = await page.evaluate(() => {
|
|
const rootElement = document.querySelector('div[contenteditable="true"]');
|
|
|
|
const getPathFromNode = (node) => {
|
|
const path = [];
|
|
if (node === rootElement) {
|
|
return [];
|
|
}
|
|
while (node !== null) {
|
|
const parent = node.parentNode;
|
|
if (parent === null || node === rootElement) {
|
|
break;
|
|
}
|
|
path.push(Array.from(parent.childNodes).indexOf(node));
|
|
node = parent;
|
|
}
|
|
return path.reverse();
|
|
};
|
|
|
|
const {anchorNode, anchorOffset, focusNode, focusOffset} =
|
|
window.getSelection();
|
|
|
|
return {
|
|
anchorOffset,
|
|
anchorPath: getPathFromNode(anchorNode),
|
|
focusOffset,
|
|
focusPath: getPathFromNode(focusNode),
|
|
};
|
|
}, expected);
|
|
expect(selection.anchorPath).toEqual(expected.anchorPath);
|
|
expect(selection.focusPath).toEqual(expected.focusPath);
|
|
if (Array.isArray(expected.anchorOffset)) {
|
|
const [start, end] = expected.anchorOffset;
|
|
expect(selection.anchorOffset).toBeGreaterThanOrEqual(start);
|
|
expect(selection.anchorOffset).toBeLessThanOrEqual(end);
|
|
} else {
|
|
expect(selection.anchorOffset).toEqual(expected.anchorOffset);
|
|
}
|
|
if (Array.isArray(expected.focusOffset)) {
|
|
const [start, end] = expected.focusOffset;
|
|
expect(selection.focusOffset).toBeGreaterThanOrEqual(start);
|
|
expect(selection.focusOffset).toBeLessThanOrEqual(end);
|
|
} else {
|
|
expect(selection.focusOffset).toEqual(expected.focusOffset);
|
|
}
|
|
}
|
|
|
|
export async function assertSelection(page, expected) {
|
|
if (IS_COLLAB) {
|
|
const frame = await page.frame('left');
|
|
await assertSelectionOnPageOrFrame(frame, expected);
|
|
} else {
|
|
await assertSelectionOnPageOrFrame(page, expected);
|
|
}
|
|
}
|
|
|
|
export async function isMac(page) {
|
|
return page.evaluate(
|
|
() =>
|
|
typeof window !== 'undefined' &&
|
|
/Mac|iPod|iPhone|iPad/.test(window.navigator.platform),
|
|
);
|
|
}
|
|
|
|
export async function supportsBeforeInput(page) {
|
|
return page.evaluate(() => {
|
|
if ('InputEvent' in window) {
|
|
return 'getTargetRanges' in new window.InputEvent('input');
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
export async function keyDownCtrlOrMeta(page) {
|
|
if (await isMac(page)) {
|
|
await page.keyboard.down('Meta');
|
|
} else {
|
|
await page.keyboard.down('Control');
|
|
}
|
|
}
|
|
|
|
export async function keyUpCtrlOrMeta(page) {
|
|
if (await isMac(page)) {
|
|
await page.keyboard.up('Meta');
|
|
} else {
|
|
await page.keyboard.up('Control');
|
|
}
|
|
}
|
|
|
|
export async function keyDownCtrlOrAlt(page) {
|
|
if (await isMac(page)) {
|
|
await page.keyboard.down('Alt');
|
|
} else {
|
|
await page.keyboard.down('Control');
|
|
}
|
|
}
|
|
|
|
export async function keyUpCtrlOrAlt(page) {
|
|
if (await isMac(page)) {
|
|
await page.keyboard.up('Alt');
|
|
} else {
|
|
await page.keyboard.up('Control');
|
|
}
|
|
}
|
|
|
|
async function copyToClipboardPageOrFrame(pageOrFrame) {
|
|
return await pageOrFrame.evaluate(() => {
|
|
const clipboardData = {};
|
|
const editor = document.querySelector('div[contenteditable="true"]');
|
|
const copyEvent = new ClipboardEvent('copy');
|
|
Object.defineProperty(copyEvent, 'clipboardData', {
|
|
value: {
|
|
setData(type, value) {
|
|
clipboardData[type] = value;
|
|
},
|
|
},
|
|
});
|
|
editor.dispatchEvent(copyEvent);
|
|
return clipboardData;
|
|
});
|
|
}
|
|
|
|
export async function copyToClipboard(page) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
return await copyToClipboardPageOrFrame(leftFrame);
|
|
} else {
|
|
return await copyToClipboardPageOrFrame(page);
|
|
}
|
|
}
|
|
|
|
async function pasteFromClipboardPageOrFrame(pageOrFrame, clipboardData) {
|
|
const canUseBeforeInput = supportsBeforeInput(pageOrFrame);
|
|
await pageOrFrame.evaluate(
|
|
async ({
|
|
clipboardData: _clipboardData,
|
|
canUseBeforeInput: _canUseBeforeInput,
|
|
}) => {
|
|
const editor = document.querySelector('div[contenteditable="true"]');
|
|
const pasteEvent = new ClipboardEvent('paste', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
Object.defineProperty(pasteEvent, 'clipboardData', {
|
|
value: {
|
|
getData(type, value) {
|
|
return _clipboardData[type];
|
|
},
|
|
},
|
|
});
|
|
editor.dispatchEvent(pasteEvent);
|
|
if (!pasteEvent.defaultPrevented) {
|
|
if (_canUseBeforeInput) {
|
|
const inputEvent = new InputEvent('beforeinput', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
Object.defineProperty(inputEvent, 'inputType', {
|
|
value: 'insertFromPaste',
|
|
});
|
|
Object.defineProperty(inputEvent, 'dataTransfer', {
|
|
value: {
|
|
getData(type, value) {
|
|
return _clipboardData[type];
|
|
},
|
|
},
|
|
});
|
|
editor.dispatchEvent(inputEvent);
|
|
}
|
|
}
|
|
},
|
|
{canUseBeforeInput, clipboardData},
|
|
);
|
|
}
|
|
|
|
export async function pasteFromClipboard(page, clipboardData) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
await pasteFromClipboardPageOrFrame(leftFrame, clipboardData);
|
|
} else {
|
|
await pasteFromClipboardPageOrFrame(page, clipboardData);
|
|
}
|
|
}
|
|
|
|
export async function sleep(delay) {
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
|
|
export async function focusEditor(page, parentSelector = '.editor-shell') {
|
|
const selector = `${parentSelector} div[contenteditable="true"]`;
|
|
if (IS_COLLAB) {
|
|
await page.waitForSelector('iframe[name="left"]');
|
|
const leftFrame = page.frame('left');
|
|
if ((await leftFrame.$$('.loading').length) !== 0) {
|
|
await leftFrame.waitForSelector('.loading', {
|
|
state: 'detached',
|
|
});
|
|
await sleep(500);
|
|
}
|
|
await leftFrame.focus(selector);
|
|
} else {
|
|
await page.focus(selector);
|
|
}
|
|
}
|
|
|
|
export async function getEditorElement(page, parentSelector = '.editor-shell') {
|
|
const selector = `${parentSelector} div[contenteditable="true"]`;
|
|
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
await leftFrame.waitForSelector(selector);
|
|
return leftFrame.$(selector);
|
|
} else {
|
|
await page.waitForSelector(selector);
|
|
return page.$(selector);
|
|
}
|
|
}
|
|
|
|
export async function waitForSelector(page, selector, options) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
await leftFrame.waitForSelector(selector, options);
|
|
} else {
|
|
await page.waitForSelector(selector, options);
|
|
}
|
|
}
|
|
|
|
export async function click(page, selector, options) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
await leftFrame.click(selector, options);
|
|
} else {
|
|
await page.click(selector, options);
|
|
}
|
|
}
|
|
|
|
export async function focus(page, selector, options) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
await leftFrame.focus(selector, options);
|
|
} else {
|
|
await page.focus(selector, options);
|
|
}
|
|
}
|
|
|
|
export async function selectOption(page, selector, options) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
await leftFrame.selectOption(selector, options);
|
|
} else {
|
|
await page.selectOption(selector, options);
|
|
}
|
|
}
|
|
|
|
export async function textContent(page, selector, options) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
return await leftFrame.textContent(selector, options);
|
|
} else {
|
|
return await page.textContent(selector, options);
|
|
}
|
|
}
|
|
|
|
export async function evaluate(page, fn, args) {
|
|
if (IS_COLLAB) {
|
|
const leftFrame = await page.frame('left');
|
|
return await leftFrame.evaluate(fn, args);
|
|
} else {
|
|
return await page.evaluate(fn, args);
|
|
}
|
|
}
|
|
|
|
export async function clearEditor(page) {
|
|
await selectAll(page);
|
|
await page.keyboard.press('Backspace');
|
|
await page.keyboard.press('Backspace');
|
|
}
|
|
|
|
export async function insertImage(page, caption = null) {
|
|
await waitForSelector(page, 'button .image');
|
|
await click(page, 'button .image');
|
|
await waitForSelector(page, '.editor-image img');
|
|
|
|
if (caption !== null) {
|
|
await click(page, '.editor-image img');
|
|
await click(page, '.image-caption-button');
|
|
await waitForSelector(page, '.editor-image img.focused', {
|
|
state: 'detached',
|
|
});
|
|
await focusEditor(page, '.image-caption-container');
|
|
await page.keyboard.type(caption);
|
|
}
|
|
}
|
|
|
|
export async function dragMouse(page, firstBoundingBox, secondBoundingBox) {
|
|
await page.mouse.move(
|
|
firstBoundingBox.x + firstBoundingBox.width / 2,
|
|
firstBoundingBox.y + firstBoundingBox.height / 2,
|
|
);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
secondBoundingBox.x + secondBoundingBox.width / 2,
|
|
secondBoundingBox.y + secondBoundingBox.height / 2,
|
|
);
|
|
await page.mouse.up();
|
|
}
|
|
|
|
export function prettifyHTML(string, {ignoreClasses, ignoreInlineStyles} = {}) {
|
|
let output = string;
|
|
|
|
if (ignoreClasses) {
|
|
output = output.replace(/\sclass="([^"]*)"/g, '');
|
|
}
|
|
|
|
if (ignoreInlineStyles) {
|
|
output = output.replace(/\sstyle="([^"]*)"/g, '');
|
|
}
|
|
|
|
return prettier
|
|
.format(output, {
|
|
attributeGroups: ['$DEFAULT', '^data-'],
|
|
attributeSort: 'ASC',
|
|
htmlWhitespaceSensitivity: 'ignore',
|
|
parser: 'html',
|
|
})
|
|
.trim();
|
|
}
|
|
|
|
// This function does not suppose to do anything, it's only used as a trigger
|
|
// for prettier auto-formatting (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
|
|
export function html(partials, ...params) {
|
|
let output = '';
|
|
for (let i = 0; i < partials.length; i++) {
|
|
output += partials[i];
|
|
if (i < partials.length - 1) {
|
|
output += params[i];
|
|
}
|
|
}
|
|
return output;
|
|
}
|