mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
22924c767c
The primary purpose of this PR appears to be: 1. Simplifying the change notification API by removing the redundant value parameter from callbacks 2. Improving the reactive system's handling of specialized types (Text and Boxed) in flat data 3. Adding better test coverage for text handling in the flat data model
1260 lines
35 KiB
TypeScript
1260 lines
35 KiB
TypeScript
import '../declare-test-window.js';
|
|
|
|
import type {
|
|
DatabaseBlockModel,
|
|
ListType,
|
|
RichText,
|
|
} from '@blocksuite/blocks';
|
|
import { assertExists } from '@blocksuite/global/utils';
|
|
import type { InlineRange, InlineRootElement } from '@blocksuite/inline';
|
|
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
|
|
import type { BlockModel } from '@blocksuite/store';
|
|
import { uuidv4 } from '@blocksuite/store';
|
|
import type { ConsoleMessage, Locator, Page } from '@playwright/test';
|
|
import { expect } from '@playwright/test';
|
|
import lz from 'lz-string';
|
|
|
|
import { currentEditorIndex } from '../multiple-editor.js';
|
|
import {
|
|
pressArrowRight,
|
|
pressEnter,
|
|
pressEscape,
|
|
pressSpace,
|
|
pressTab,
|
|
selectAllBlocksByKeyboard,
|
|
SHORT_KEY,
|
|
type,
|
|
} from './keyboard.js';
|
|
|
|
export const defaultPlaygroundURL = new URL(
|
|
`http://localhost:${process.env.CI ? 4173 : 5173}/`
|
|
);
|
|
|
|
const NEXT_FRAME_TIMEOUT = 50;
|
|
const DEFAULT_PLAYGROUND = defaultPlaygroundURL.toString();
|
|
const RICH_TEXT_SELECTOR = '.inline-editor';
|
|
|
|
function generateRandomRoomId() {
|
|
return `playwright-${uuidv4()}`;
|
|
}
|
|
|
|
export const getSelectionRect = async (page: Page): Promise<DOMRect> => {
|
|
const rect = await page.evaluate(() => {
|
|
return getSelection()?.getRangeAt(0).getBoundingClientRect();
|
|
});
|
|
assertExists(rect);
|
|
return rect;
|
|
};
|
|
|
|
export const getEditorLocator = (page: Page) => {
|
|
return page.locator('affine-editor-container').nth(currentEditorIndex);
|
|
};
|
|
|
|
export const getEditorHostLocator = (page: Page) => {
|
|
return page.locator('editor-host').nth(currentEditorIndex);
|
|
};
|
|
|
|
type TaggedConsoleMessage = ConsoleMessage & { __ignore?: boolean };
|
|
function ignoredLog(message: ConsoleMessage) {
|
|
(message as TaggedConsoleMessage).__ignore = true;
|
|
}
|
|
function isIgnoredLog(
|
|
message: ConsoleMessage
|
|
): message is TaggedConsoleMessage {
|
|
return '__ignore' in message && !!message.__ignore;
|
|
}
|
|
|
|
/**
|
|
* Expect console message to be called in the test.
|
|
*
|
|
* This function **should** be called before the `enterPlaygroundRoom` function!
|
|
*
|
|
* ```ts
|
|
* expectConsoleMessage(page, 'Failed to load resource'); // Default type is 'error'
|
|
* expectConsoleMessage(page, '[vite] connected.', 'warning'); // Specify type
|
|
* ```
|
|
*/
|
|
export function expectConsoleMessage(
|
|
page: Page,
|
|
logPrefixOrRegex: string | RegExp,
|
|
type:
|
|
| 'log'
|
|
| 'debug'
|
|
| 'info'
|
|
| 'error'
|
|
| 'warning'
|
|
| 'dir'
|
|
| 'dirxml'
|
|
| 'table'
|
|
| 'trace'
|
|
| 'clear'
|
|
| 'startGroup'
|
|
| 'startGroupCollapsed'
|
|
| 'endGroup'
|
|
| 'assert'
|
|
| 'profile'
|
|
| 'profileEnd'
|
|
| 'count'
|
|
| 'timeEnd' = 'error'
|
|
) {
|
|
page.on('console', (message: ConsoleMessage) => {
|
|
const sameType = message.type() === type;
|
|
const textMatch =
|
|
logPrefixOrRegex instanceof RegExp
|
|
? logPrefixOrRegex.test(message.text())
|
|
: message.text().startsWith(logPrefixOrRegex);
|
|
if (sameType && textMatch) {
|
|
ignoredLog(message);
|
|
}
|
|
});
|
|
}
|
|
|
|
export type PlaygroundRoomOptions = {
|
|
room?: string;
|
|
blobSource?: ('idb' | 'mock')[];
|
|
noInit?: boolean;
|
|
};
|
|
export async function enterPlaygroundRoom(
|
|
page: Page,
|
|
ops?: PlaygroundRoomOptions
|
|
) {
|
|
const url = new URL(DEFAULT_PLAYGROUND);
|
|
let room = ops?.room;
|
|
const blobSource = ops?.blobSource;
|
|
if (!room) {
|
|
room = generateRandomRoomId();
|
|
}
|
|
url.searchParams.set('room', room);
|
|
url.searchParams.set('blobSource', blobSource?.join(',') || 'idb');
|
|
if (ops?.noInit) {
|
|
url.searchParams.set('noInit', 'true');
|
|
}
|
|
await page.goto(url.toString());
|
|
|
|
// See https://github.com/microsoft/playwright/issues/5546
|
|
page.on('console', message => {
|
|
if (
|
|
[
|
|
// React devtools:
|
|
'%cDownload the React DevTools for a better development experience: https://reactjs.org/link/react-devtools font-weight:bold',
|
|
// Vite:
|
|
'[vite] connected.',
|
|
'[vite] connecting...',
|
|
// Figma embed:
|
|
'Fullscreen: Using 4GB WASM heap',
|
|
// Lit:
|
|
'Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information.',
|
|
// Figma embed:
|
|
'Running frontend commit',
|
|
// Github timeout:
|
|
'Failed to load resource: the server responded with a status of 403',
|
|
].some(text => message.text().startsWith(text))
|
|
) {
|
|
return;
|
|
}
|
|
const ignore = isIgnoredLog(message) || !process.env.CI;
|
|
if (!ignore) {
|
|
expect
|
|
.soft('Unexpected console message: ' + message.text())
|
|
.toBe(
|
|
'Please remove the "console.log" or declare `expectConsoleMessage` before `enterPlaygroundRoom`. It is advised not to output logs in a production environment.'
|
|
);
|
|
}
|
|
console.log(`Console ${message.type()}: ${message.text()}`);
|
|
});
|
|
|
|
// Log all uncaught errors
|
|
page.on('pageerror', exception => {
|
|
throw new Error(`Uncaught exception: "${exception}"\n${exception.stack}`);
|
|
});
|
|
|
|
const locator = page.locator('affine-editor-container');
|
|
await locator.isVisible();
|
|
await page.evaluate(async () => {
|
|
const dom = document.querySelector<TestAffineEditorContainer>(
|
|
'affine-editor-container'
|
|
);
|
|
if (dom) {
|
|
await dom.updateComplete;
|
|
}
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
if (typeof window.$blocksuite !== 'object') {
|
|
throw new Error('window.$blocksuite is not object');
|
|
}
|
|
}, []);
|
|
return room;
|
|
}
|
|
|
|
export async function waitDefaultPageLoaded(page: Page) {
|
|
await page.waitForSelector('affine-page-root[data-block-id="0"]');
|
|
}
|
|
|
|
export async function waitEmbedLoaded(page: Page) {
|
|
await page.waitForSelector('.resizable-img');
|
|
}
|
|
|
|
export async function waitNextFrame(
|
|
page: Page,
|
|
frameTimeout = NEXT_FRAME_TIMEOUT
|
|
) {
|
|
await page.waitForTimeout(frameTimeout);
|
|
}
|
|
|
|
export async function clearLog(page: Page) {
|
|
await page.evaluate(() => console.clear());
|
|
}
|
|
|
|
export async function captureHistory(page: Page) {
|
|
await page.evaluate(() => {
|
|
window.doc.captureSync();
|
|
});
|
|
}
|
|
|
|
export async function resetHistory(page: Page) {
|
|
await page.evaluate(() => {
|
|
const space = window.doc;
|
|
space.resetHistory();
|
|
});
|
|
}
|
|
|
|
// XXX: This doesn't add surface yet, the page state should not be switched to edgeless.
|
|
export async function enterPlaygroundWithList(
|
|
page: Page,
|
|
contents: string[] = ['', '', ''],
|
|
type: ListType = 'bulleted'
|
|
) {
|
|
const room = generateRandomRoomId();
|
|
await page.goto(`${DEFAULT_PLAYGROUND}?room=${room}`);
|
|
|
|
await page.evaluate(
|
|
({ contents, type }: { contents: string[]; type: ListType }) => {
|
|
const { doc } = window;
|
|
const rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|
for (let i = 0; i < contents.length; i++) {
|
|
doc.addBlock(
|
|
'affine:list',
|
|
contents.length > 0
|
|
? { text: new window.$blocksuite.store.Text(contents[i]), type }
|
|
: { type },
|
|
noteId
|
|
);
|
|
}
|
|
},
|
|
{ contents, type }
|
|
);
|
|
await waitNextFrame(page);
|
|
}
|
|
|
|
// XXX: This doesn't add surface yet, the doc state should not be switched to edgeless.
|
|
export async function initEmptyParagraphState(page: Page, rootId?: string) {
|
|
const ids = await page.evaluate(rootId => {
|
|
const { doc } = window;
|
|
doc.captureSync();
|
|
if (!rootId) {
|
|
rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
}
|
|
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
|
|
// doc.addBlock('affine:surface', {}, rootId);
|
|
doc.captureSync();
|
|
|
|
return { rootId, noteId, paragraphId };
|
|
}, rootId);
|
|
return ids;
|
|
}
|
|
|
|
export async function initMultipleNoteWithParagraphState(
|
|
page: Page,
|
|
rootId?: string,
|
|
count = 2
|
|
) {
|
|
const ids = await page.evaluate(
|
|
({ rootId, count }) => {
|
|
const { doc } = window;
|
|
doc.captureSync();
|
|
if (!rootId) {
|
|
rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
}
|
|
|
|
const ids = Array.from({ length: count })
|
|
.fill(0)
|
|
.map(() => {
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
|
|
return { noteId, paragraphId };
|
|
});
|
|
|
|
// doc.addBlock('affine:surface', {}, rootId);
|
|
doc.captureSync();
|
|
|
|
return { rootId, ids };
|
|
},
|
|
{ rootId, count }
|
|
);
|
|
return ids;
|
|
}
|
|
|
|
export async function initEmptyEdgelessState(page: Page) {
|
|
const ids = await page.evaluate(() => {
|
|
const { doc } = window;
|
|
const rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
doc.addBlock('affine:surface', {}, rootId);
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
|
|
|
|
doc.resetHistory();
|
|
|
|
return { rootId, noteId, paragraphId };
|
|
});
|
|
return ids;
|
|
}
|
|
|
|
export async function initEmptyDatabaseState(page: Page, rootId?: string) {
|
|
const ids = await page.evaluate(async rootId => {
|
|
const { doc } = window;
|
|
doc.captureSync();
|
|
if (!rootId) {
|
|
rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
}
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
const databaseId = doc.addBlock(
|
|
'affine:database',
|
|
{
|
|
title: new window.$blocksuite.store.Text('Database 1'),
|
|
},
|
|
noteId
|
|
);
|
|
const model = doc.getBlockById(databaseId) as DatabaseBlockModel;
|
|
const datasource = new window.$blocksuite.blocks.DatabaseBlockDataSource(
|
|
model
|
|
);
|
|
datasource.viewManager.viewAdd('table');
|
|
doc.captureSync();
|
|
return { rootId, noteId, databaseId };
|
|
}, rootId);
|
|
return ids;
|
|
}
|
|
|
|
export async function initKanbanViewState(
|
|
page: Page,
|
|
config: {
|
|
rows: string[];
|
|
columns: { type: string; value?: unknown[] }[];
|
|
},
|
|
rootId?: string
|
|
) {
|
|
const ids = await page.evaluate(
|
|
async ({ rootId, config }) => {
|
|
const { doc } = window;
|
|
|
|
doc.captureSync();
|
|
if (!rootId) {
|
|
rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
}
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
const databaseId = doc.addBlock(
|
|
'affine:database',
|
|
{
|
|
title: new window.$blocksuite.store.Text('Database 1'),
|
|
},
|
|
noteId
|
|
);
|
|
const model = doc.getBlockById(databaseId) as DatabaseBlockModel;
|
|
const datasource = new window.$blocksuite.blocks.DatabaseBlockDataSource(
|
|
model
|
|
);
|
|
const rowIds = config.rows.map(rowText => {
|
|
const rowId = doc.addBlock(
|
|
'affine:paragraph',
|
|
{ type: 'text', text: new window.$blocksuite.store.Text(rowText) },
|
|
databaseId
|
|
);
|
|
return rowId;
|
|
});
|
|
config.columns.forEach(column => {
|
|
const columnId = datasource.propertyAdd('end', column.type);
|
|
datasource.propertyNameSet(columnId, column.type);
|
|
rowIds.forEach((rowId, index) => {
|
|
const value = column.value?.[index];
|
|
if (value !== undefined) {
|
|
datasource.cellValueChange(
|
|
rowId,
|
|
columnId,
|
|
column.type === 'rich-text'
|
|
? new window.$blocksuite.store.Text(value as string)
|
|
: value
|
|
);
|
|
}
|
|
});
|
|
});
|
|
datasource.viewManager.viewAdd('kanban');
|
|
doc.captureSync();
|
|
return { rootId, noteId, databaseId };
|
|
},
|
|
{ rootId, config }
|
|
);
|
|
return ids;
|
|
}
|
|
|
|
export async function initEmptyDatabaseWithParagraphState(
|
|
page: Page,
|
|
rootId?: string
|
|
) {
|
|
const ids = await page.evaluate(async rootId => {
|
|
const { doc } = window;
|
|
doc.captureSync();
|
|
if (!rootId) {
|
|
rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
}
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
const databaseId = doc.addBlock(
|
|
'affine:database',
|
|
{
|
|
title: new window.$blocksuite.store.Text('Database 1'),
|
|
},
|
|
noteId
|
|
);
|
|
const model = doc.getBlockById(databaseId) as DatabaseBlockModel;
|
|
const datasource = new window.$blocksuite.blocks.DatabaseBlockDataSource(
|
|
model
|
|
);
|
|
datasource.viewManager.viewAdd('table');
|
|
doc.addBlock('affine:paragraph', {}, noteId);
|
|
doc.captureSync();
|
|
return { rootId, noteId, databaseId };
|
|
}, rootId);
|
|
return ids;
|
|
}
|
|
|
|
export async function initDatabaseRow(page: Page) {
|
|
const editorHost = getEditorHostLocator(page);
|
|
const addRow = editorHost.locator('.data-view-table-group-add-row');
|
|
await addRow.click();
|
|
}
|
|
|
|
export async function initDatabaseRowWithData(page: Page, data: string) {
|
|
await initDatabaseRow(page);
|
|
await waitNextFrame(page, 50);
|
|
await type(page, data);
|
|
}
|
|
export const getAddRow = (page: Page): Locator => {
|
|
return page.locator('.data-view-table-group-add-row');
|
|
};
|
|
export async function initDatabaseDynamicRowWithData(
|
|
page: Page,
|
|
data: string,
|
|
addRow = false,
|
|
index = 0
|
|
) {
|
|
const editorHost = getEditorHostLocator(page);
|
|
if (addRow) {
|
|
await initDatabaseRow(page);
|
|
await waitNextFrame(page, 100);
|
|
await pressEscape(page);
|
|
}
|
|
const lastRow = editorHost.locator('.affine-database-block-row').last();
|
|
const cell = lastRow.locator('.database-cell').nth(index + 1);
|
|
await cell.click();
|
|
await waitNextFrame(page);
|
|
await pressEnter(page);
|
|
await waitNextFrame(page);
|
|
await type(page, data);
|
|
await pressEnter(page);
|
|
}
|
|
|
|
export async function focusDatabaseTitle(page: Page) {
|
|
const dbTitle = page.locator('[data-block-is-database-title="true"]');
|
|
await dbTitle.click();
|
|
|
|
await page.evaluate(() => {
|
|
const dbTitle = document.querySelector(
|
|
'affine-database-title textarea'
|
|
) as HTMLTextAreaElement | null;
|
|
if (!dbTitle) {
|
|
throw new Error('Cannot find database title');
|
|
}
|
|
|
|
dbTitle.focus();
|
|
});
|
|
await selectAllBlocksByKeyboard(page);
|
|
await pressArrowRight(page);
|
|
await waitNextFrame(page);
|
|
}
|
|
|
|
export async function assertDatabaseColumnOrder(page: Page, order: string[]) {
|
|
const columns = await page
|
|
.locator('affine-database-column-header')
|
|
.locator('affine-database-header-column')
|
|
.all();
|
|
expect(await Promise.all(columns.slice(1).map(v => v.innerText()))).toEqual(
|
|
order
|
|
);
|
|
}
|
|
|
|
export async function initEmptyCodeBlockState(
|
|
page: Page,
|
|
codeBlockProps = {} as { language?: string }
|
|
) {
|
|
const ids = await page.evaluate(codeBlockProps => {
|
|
const { doc } = window;
|
|
doc.captureSync();
|
|
const rootId = doc.addBlock('affine:page');
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
const codeBlockId = doc.addBlock('affine:code', codeBlockProps, noteId);
|
|
doc.captureSync();
|
|
|
|
return { rootId, noteId, codeBlockId };
|
|
}, codeBlockProps);
|
|
await page.waitForSelector(`[data-block-id="${ids.codeBlockId}"] rich-text`);
|
|
return ids;
|
|
}
|
|
|
|
type FocusRichTextOptions = {
|
|
clickPosition?: { x: number; y: number };
|
|
};
|
|
|
|
export async function focusRichText(
|
|
page: Page,
|
|
i = 0,
|
|
options?: FocusRichTextOptions
|
|
) {
|
|
await page.mouse.move(0, 0);
|
|
const editor = getEditorHostLocator(page);
|
|
const locator = editor.locator(RICH_TEXT_SELECTOR).nth(i);
|
|
// need to set `force` to true when clicking on `affine-selected-blocks`
|
|
await locator.click({ force: true, position: options?.clickPosition });
|
|
}
|
|
|
|
export async function focusRichTextEnd(page: Page, i = 0) {
|
|
await page.evaluate(
|
|
([i, currentEditorIndex]) => {
|
|
const editorHost =
|
|
document.querySelectorAll('editor-host')[currentEditorIndex];
|
|
const richTexts = Array.from(editorHost.querySelectorAll('rich-text'));
|
|
|
|
richTexts[i].inlineEditor?.focusEnd();
|
|
},
|
|
[i, currentEditorIndex]
|
|
);
|
|
await waitNextFrame(page);
|
|
}
|
|
|
|
export async function initThreeParagraphs(page: Page) {
|
|
await focusRichText(page);
|
|
await type(page, '123');
|
|
await pressEnter(page);
|
|
await type(page, '456');
|
|
await pressEnter(page);
|
|
await type(page, '789');
|
|
await resetHistory(page);
|
|
}
|
|
|
|
export async function initSixParagraphs(page: Page) {
|
|
await focusRichText(page);
|
|
await type(page, '1');
|
|
await pressEnter(page);
|
|
await type(page, '2');
|
|
await pressEnter(page);
|
|
await type(page, '3');
|
|
await pressEnter(page);
|
|
await type(page, '4');
|
|
await pressEnter(page);
|
|
await type(page, '5');
|
|
await pressEnter(page);
|
|
await type(page, '6');
|
|
await resetHistory(page);
|
|
}
|
|
|
|
export async function initThreeLists(page: Page) {
|
|
await focusRichText(page);
|
|
await type(page, '-');
|
|
await pressSpace(page);
|
|
await type(page, '123');
|
|
await pressEnter(page);
|
|
await type(page, '456');
|
|
await pressEnter(page);
|
|
await pressTab(page);
|
|
await type(page, '789');
|
|
}
|
|
|
|
export async function insertThreeLevelLists(page: Page, i = 0) {
|
|
await focusRichText(page, i);
|
|
await type(page, '-');
|
|
await pressSpace(page);
|
|
await type(page, '123');
|
|
await pressEnter(page);
|
|
await pressTab(page);
|
|
await type(page, '456');
|
|
await pressEnter(page);
|
|
await pressTab(page);
|
|
await type(page, '789');
|
|
}
|
|
|
|
export async function initThreeDividers(page: Page) {
|
|
await focusRichText(page);
|
|
await type(page, '123');
|
|
await pressEnter(page);
|
|
await type(page, '---');
|
|
await pressSpace(page);
|
|
await type(page, '---');
|
|
await pressSpace(page);
|
|
await type(page, '---');
|
|
await pressSpace(page);
|
|
await type(page, '123');
|
|
}
|
|
|
|
export async function initParagraphsByCount(page: Page, count: number) {
|
|
await focusRichText(page);
|
|
for (let i = 0; i < count; i++) {
|
|
await type(page, `paragraph ${i}`);
|
|
await pressEnter(page);
|
|
}
|
|
await resetHistory(page);
|
|
}
|
|
|
|
export async function getInlineSelectionIndex(page: Page) {
|
|
return page.evaluate(() => {
|
|
const selection = window.getSelection() as Selection;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const component = range.startContainer.parentElement?.closest('rich-text');
|
|
const index = component?.inlineEditor?.getInlineRange()?.index;
|
|
return index !== undefined ? index : -1;
|
|
});
|
|
}
|
|
|
|
export async function getInlineSelectionText(page: Page) {
|
|
return page.evaluate(() => {
|
|
const selection = window.getSelection() as Selection;
|
|
const range = selection.getRangeAt(0);
|
|
const component = range.startContainer.parentElement?.closest('rich-text');
|
|
return component?.inlineEditor?.yText.toString() ?? '';
|
|
});
|
|
}
|
|
|
|
export async function getSelectedTextByInlineEditor(page: Page) {
|
|
return page.evaluate(() => {
|
|
const selection = window.getSelection() as Selection;
|
|
const range = selection.getRangeAt(0);
|
|
const component = range.startContainer.parentElement?.closest('rich-text');
|
|
|
|
const inlineRange = component?.inlineEditor?.getInlineRange();
|
|
if (!inlineRange) return '';
|
|
|
|
const { index, length } = inlineRange;
|
|
return (
|
|
component?.inlineEditor?.yText.toString().slice(index, index + length) ||
|
|
''
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function getSelectedText(page: Page) {
|
|
return page.evaluate(() => {
|
|
let content = '';
|
|
const selection = window.getSelection() as Selection;
|
|
|
|
if (selection.rangeCount === 0) return content;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const components =
|
|
range.commonAncestorContainer.parentElement?.querySelectorAll(
|
|
'rich-text'
|
|
) || [];
|
|
|
|
components.forEach(component => {
|
|
const inlineRange = component.inlineEditor?.getInlineRange();
|
|
if (!inlineRange) return;
|
|
const { index, length } = inlineRange;
|
|
content +=
|
|
component?.inlineEditor?.yText
|
|
.toString()
|
|
.slice(index, index + length) || '';
|
|
});
|
|
|
|
return content;
|
|
});
|
|
}
|
|
|
|
export async function setInlineRangeInSelectedRichText(
|
|
page: Page,
|
|
index: number,
|
|
length: number
|
|
) {
|
|
await page.evaluate(
|
|
({ index, length }) => {
|
|
const selection = window.getSelection() as Selection;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const component =
|
|
range.startContainer.parentElement?.closest('rich-text');
|
|
component?.inlineEditor?.setInlineRange({
|
|
index,
|
|
length,
|
|
});
|
|
},
|
|
{ index, length }
|
|
);
|
|
await waitNextFrame(page);
|
|
}
|
|
|
|
export async function setInlineRangeInInlineEditor(
|
|
page: Page,
|
|
inlineRange: InlineRange,
|
|
i = 0
|
|
) {
|
|
await page.evaluate(
|
|
({ i, inlineRange }) => {
|
|
const inlineEditor = document.querySelectorAll<InlineRootElement>(
|
|
'[data-v-root="true"]'
|
|
)[i]?.inlineEditor;
|
|
if (!inlineEditor) {
|
|
throw new Error('Cannot find inline editor');
|
|
}
|
|
inlineEditor.setInlineRange(inlineRange);
|
|
},
|
|
{ i, inlineRange }
|
|
);
|
|
await waitNextFrame(page);
|
|
}
|
|
|
|
export async function pasteContent(
|
|
page: Page,
|
|
clipData: Record<string, unknown>
|
|
) {
|
|
await page.evaluate(
|
|
({ clipData }) => {
|
|
const e = new ClipboardEvent('paste', {
|
|
clipboardData: new DataTransfer(),
|
|
});
|
|
Object.defineProperty(e, 'target', {
|
|
writable: false,
|
|
value: document,
|
|
});
|
|
Object.keys(clipData).forEach(key => {
|
|
e.clipboardData?.setData(key, clipData[key] as string);
|
|
});
|
|
document.dispatchEvent(e);
|
|
},
|
|
{ clipData }
|
|
);
|
|
await waitNextFrame(page);
|
|
}
|
|
|
|
export async function pasteTestImage(page: Page) {
|
|
await page.evaluate(async () => {
|
|
const imageBlob = await fetch(`${location.origin}/test-card-1.png`).then(
|
|
response => response.blob()
|
|
);
|
|
|
|
const imageFile = new File([imageBlob], 'test-card-1.png', {
|
|
type: 'image/png',
|
|
});
|
|
|
|
const e = new ClipboardEvent('paste', {
|
|
clipboardData: new DataTransfer(),
|
|
});
|
|
|
|
Object.defineProperty(e, 'target', {
|
|
writable: false,
|
|
value: document,
|
|
});
|
|
|
|
e.clipboardData?.items.add(imageFile);
|
|
document.dispatchEvent(e);
|
|
});
|
|
await waitNextFrame(page);
|
|
}
|
|
|
|
export async function getClipboardHTML(page: Page) {
|
|
const dataInClipboard = await page.evaluate(async () => {
|
|
function format(node: HTMLElement, level: number) {
|
|
const indentBefore = ' '.repeat(level++);
|
|
const indentAfter = ' '.repeat(level >= 2 ? level - 2 : 0);
|
|
let textNode;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|
for (let i = 0; i < node.children.length; i++) {
|
|
textNode = document.createTextNode('\n' + indentBefore);
|
|
node.insertBefore(textNode, node.children[i]);
|
|
|
|
format(node.children[i] as HTMLElement, level);
|
|
|
|
if (node.lastElementChild == node.children[i]) {
|
|
textNode = document.createTextNode('\n' + indentAfter);
|
|
node.append(textNode);
|
|
}
|
|
}
|
|
|
|
return node;
|
|
}
|
|
const clipItems = await navigator.clipboard.read();
|
|
const item = clipItems.find(item => item.types.includes('text/html'));
|
|
const data = await item?.getType('text/html');
|
|
const text = await data?.text();
|
|
const html = new DOMParser().parseFromString(text ?? '', 'text/html');
|
|
const container = html.querySelector<HTMLDivElement>(
|
|
'[data-blocksuite-snapshot]'
|
|
);
|
|
if (!container) {
|
|
return '';
|
|
}
|
|
return format(container, 0).innerHTML.trim();
|
|
});
|
|
|
|
return dataInClipboard;
|
|
}
|
|
|
|
export async function getClipboardText(page: Page) {
|
|
const dataInClipboard = await page.evaluate(async () => {
|
|
const clipItems = await navigator.clipboard.read();
|
|
const item = clipItems.find(item => item.types.includes('text/plain'));
|
|
const data = await item?.getType('text/plain');
|
|
const text = await data?.text();
|
|
return text ?? '';
|
|
});
|
|
return dataInClipboard;
|
|
}
|
|
|
|
export async function getClipboardCustomData(page: Page, type: string) {
|
|
const dataInClipboard = await page.evaluate(async () => {
|
|
const clipItems = await navigator.clipboard.read();
|
|
const item = clipItems.find(item => item.types.includes('text/html'));
|
|
const data = await item?.getType('text/html');
|
|
const text = await data?.text();
|
|
const html = new DOMParser().parseFromString(text ?? '', 'text/html');
|
|
const container = html.querySelector<HTMLDivElement>(
|
|
'[data-blocksuite-snapshot]'
|
|
);
|
|
return container?.dataset.blocksuiteSnapshot ?? '';
|
|
});
|
|
|
|
const decompressed = lz.decompressFromEncodedURIComponent(dataInClipboard);
|
|
let json: Record<string, unknown> | null = null;
|
|
try {
|
|
json = JSON.parse(decompressed);
|
|
} catch {
|
|
throw new Error(`Invalid snapshot in clipboard: ${dataInClipboard}`);
|
|
}
|
|
|
|
return json?.[type];
|
|
}
|
|
|
|
export async function getClipboardSnapshot(page: Page) {
|
|
const dataInClipboard = await getClipboardCustomData(
|
|
page,
|
|
'BLOCKSUITE/SNAPSHOT'
|
|
);
|
|
assertExists(dataInClipboard);
|
|
const json = JSON.parse(dataInClipboard as string);
|
|
return json;
|
|
}
|
|
|
|
export async function getPageSnapshot(page: Page, toJSON?: boolean) {
|
|
const json = await page.evaluate(() => {
|
|
const { job, doc } = window;
|
|
const snapshot = job.docToSnapshot(doc);
|
|
if (!snapshot) {
|
|
throw new Error('Failed to get snapshot');
|
|
}
|
|
return snapshot.blocks;
|
|
});
|
|
if (toJSON) {
|
|
return JSON.stringify(json, null, 2);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
export async function setSelection(
|
|
page: Page,
|
|
anchorBlockId: number,
|
|
anchorOffset: number,
|
|
focusBlockId: number,
|
|
focusOffset: number
|
|
) {
|
|
await page.evaluate(
|
|
({
|
|
anchorBlockId,
|
|
anchorOffset,
|
|
focusBlockId,
|
|
focusOffset,
|
|
currentEditorIndex,
|
|
}) => {
|
|
const editorHost =
|
|
document.querySelectorAll('editor-host')[currentEditorIndex];
|
|
const anchorRichText = editorHost.querySelector<RichText>(
|
|
`[data-block-id="${anchorBlockId}"] rich-text`
|
|
)!;
|
|
const anchorRichTextRange = anchorRichText.inlineEditor!.toDomRange({
|
|
index: anchorOffset,
|
|
length: 0,
|
|
})!;
|
|
const focusRichText = editorHost.querySelector<RichText>(
|
|
`[data-block-id="${focusBlockId}"] rich-text`
|
|
)!;
|
|
const focusRichTextRange = focusRichText.inlineEditor!.toDomRange({
|
|
index: focusOffset,
|
|
length: 0,
|
|
})!;
|
|
|
|
const sl = getSelection();
|
|
if (!sl) throw new Error('Cannot get selection');
|
|
const range = document.createRange();
|
|
range.setStart(
|
|
anchorRichTextRange.startContainer,
|
|
anchorRichTextRange.startOffset
|
|
);
|
|
range.setEnd(
|
|
focusRichTextRange.startContainer,
|
|
focusRichTextRange.startOffset
|
|
);
|
|
sl.removeAllRanges();
|
|
sl.addRange(range);
|
|
},
|
|
{
|
|
anchorBlockId,
|
|
anchorOffset,
|
|
focusBlockId,
|
|
focusOffset,
|
|
currentEditorIndex,
|
|
}
|
|
);
|
|
}
|
|
|
|
export async function readClipboardText(
|
|
page: Page,
|
|
type: 'input' | 'textarea' = 'input'
|
|
) {
|
|
const id = 'clipboard-test';
|
|
const selector = `#${id}`;
|
|
await page.evaluate(
|
|
({ type, id }) => {
|
|
const input = document.createElement(type);
|
|
input.setAttribute('id', id);
|
|
document.body.append(input);
|
|
},
|
|
{ type, id }
|
|
);
|
|
const input = page.locator(selector);
|
|
await input.focus();
|
|
await page.keyboard.press(`${SHORT_KEY}+v`);
|
|
const text = await input.inputValue();
|
|
await page.evaluate(
|
|
({ selector }) => {
|
|
const input = document.querySelector(selector);
|
|
input?.remove();
|
|
},
|
|
{ selector }
|
|
);
|
|
return text;
|
|
}
|
|
|
|
export const getCenterPositionByLocator: (
|
|
page: Page,
|
|
locator: Locator
|
|
) => Promise<{ x: number; y: number }> = async (
|
|
_page: Page,
|
|
locator: Locator
|
|
) => {
|
|
const box = await locator.boundingBox();
|
|
if (!box) {
|
|
throw new Error("Failed to getCenterPosition! Can't get bounding box");
|
|
}
|
|
return {
|
|
x: box.x + box.width / 2,
|
|
y: box.y + box.height / 2,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* @deprecated Use `page.locator(selector).boundingBox()` instead
|
|
*/
|
|
export const getBoundingClientRect: (
|
|
page: Page,
|
|
selector: string
|
|
) => Promise<DOMRect> = async (page: Page, selector: string) => {
|
|
return page.evaluate((selector: string) => {
|
|
return document.querySelector(selector)?.getBoundingClientRect() as DOMRect;
|
|
}, selector);
|
|
};
|
|
|
|
export async function getBoundingBox(locator: Locator) {
|
|
const box = await locator.boundingBox();
|
|
if (!box) throw new Error('Missing column box');
|
|
return box;
|
|
}
|
|
|
|
export async function getBlockModel<Model extends BlockModel>(
|
|
page: Page,
|
|
blockId: string
|
|
) {
|
|
const result: BlockModel | null | undefined = await page.evaluate(blockId => {
|
|
return window.doc?.getBlock(blockId)?.model;
|
|
}, blockId);
|
|
expect(result).not.toBeNull();
|
|
return result as Model;
|
|
}
|
|
|
|
export async function getIndexCoordinate(
|
|
page: Page,
|
|
[richTextIndex, vIndex]: [number, number],
|
|
coordOffSet: { x: number; y: number } = { x: 0, y: 0 }
|
|
) {
|
|
const coord = await page.evaluate(
|
|
({ richTextIndex, vIndex, coordOffSet, currentEditorIndex }) => {
|
|
const editorHost =
|
|
document.querySelectorAll('editor-host')[currentEditorIndex];
|
|
const richText = editorHost.querySelectorAll('rich-text')[
|
|
richTextIndex
|
|
] as any;
|
|
const domRange = richText.inlineEditor.toDomRange({
|
|
index: vIndex,
|
|
length: 0,
|
|
});
|
|
const pointBound = domRange.getBoundingClientRect();
|
|
return {
|
|
x: pointBound.left + coordOffSet.x,
|
|
y: pointBound.top + pointBound.height / 2 + coordOffSet.y,
|
|
};
|
|
},
|
|
{
|
|
richTextIndex,
|
|
vIndex,
|
|
coordOffSet,
|
|
currentEditorIndex,
|
|
}
|
|
);
|
|
return coord;
|
|
}
|
|
|
|
export function inlineEditorInnerTextToString(innerText: string): string {
|
|
return innerText.replace('\u200B', '').trim();
|
|
}
|
|
|
|
export async function focusTitle(page: Page) {
|
|
await page.locator('doc-title rich-text').click();
|
|
await page.evaluate(i => {
|
|
const docTitle = document.querySelectorAll('doc-title')[i];
|
|
if (!docTitle) {
|
|
throw new Error('Doc title component not found');
|
|
}
|
|
const docTitleRichText = docTitle.querySelector('rich-text');
|
|
if (!docTitleRichText) {
|
|
throw new Error('Doc title rich text component not found');
|
|
}
|
|
if (!docTitleRichText.inlineEditor) {
|
|
throw new Error('Doc title inline editor not found');
|
|
}
|
|
docTitleRichText.inlineEditor.focusEnd();
|
|
}, currentEditorIndex);
|
|
await waitNextFrame(page, 200);
|
|
}
|
|
|
|
/**
|
|
* XXX: this is a workaround for the bug in Playwright
|
|
*/
|
|
export async function shamefullyBlurActiveElement(page: Page) {
|
|
await page.evaluate(() => {
|
|
if (
|
|
!document.activeElement ||
|
|
!(document.activeElement instanceof HTMLElement)
|
|
) {
|
|
throw new Error("document.activeElement doesn't exist");
|
|
}
|
|
document.activeElement.blur();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* FIXME:
|
|
* Sometimes inline editor state is not updated in time. Bad case like below:
|
|
*
|
|
* ```
|
|
* await focusRichText(page);
|
|
* await type(page, 'hello');
|
|
* await assertRichTexts(page, ['hello']);
|
|
* ```
|
|
*
|
|
* output(failed or flaky):
|
|
*
|
|
* ```
|
|
* - Expected - 1
|
|
* + Received + 1
|
|
* Array [
|
|
* - "hello",
|
|
* + "ello",
|
|
* ]
|
|
* ```
|
|
*
|
|
*/
|
|
export async function waitForInlineEditorStateUpdated(page: Page) {
|
|
return page.evaluate(async () => {
|
|
const selection = window.getSelection() as Selection;
|
|
|
|
if (selection.rangeCount === 0) return;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const component = range.startContainer.parentElement?.closest('rich-text');
|
|
await component?.inlineEditor?.waitForUpdate();
|
|
});
|
|
}
|
|
|
|
export async function initImageState(page: Page, prependParagraph = false) {
|
|
await page.evaluate(async prepend => {
|
|
const { doc } = window;
|
|
const rootId = doc.addBlock('affine:page', {
|
|
title: new window.$blocksuite.store.Text(),
|
|
});
|
|
const noteId = doc.addBlock('affine:note', {}, rootId);
|
|
|
|
await new Promise(res => setTimeout(res, 200));
|
|
|
|
const pageRoot = document.querySelector('affine-page-root');
|
|
if (!pageRoot) throw new Error('Cannot find doc page');
|
|
const imageBlob = await fetch(`${location.origin}/test-card-1.png`).then(
|
|
response => response.blob()
|
|
);
|
|
const storage = pageRoot.doc.blobSync;
|
|
const sourceId = await storage.set(imageBlob);
|
|
if (prepend) {
|
|
doc.addBlock('affine:paragraph', {}, noteId);
|
|
}
|
|
const imageId = doc.addBlock(
|
|
'affine:image',
|
|
{
|
|
sourceId,
|
|
},
|
|
noteId
|
|
);
|
|
|
|
doc.resetHistory();
|
|
|
|
return { rootId, noteId, imageId };
|
|
}, prependParagraph);
|
|
|
|
// due to pasting img calls fetch, so we need timeout for downloading finished.
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
export async function getCurrentEditorDocId(page: Page) {
|
|
return page.evaluate(index => {
|
|
const editor = document.querySelectorAll('affine-editor-container')[index];
|
|
if (!editor) throw new Error("Can't find affine-editor-container");
|
|
const docId = editor.doc.id;
|
|
return docId;
|
|
}, currentEditorIndex);
|
|
}
|
|
|
|
export async function getCurrentHTMLTheme(page: Page) {
|
|
const root = page.locator('html');
|
|
|
|
return root.getAttribute('data-theme');
|
|
}
|
|
|
|
export async function getCurrentEditorTheme(page: Page) {
|
|
const mode = await page
|
|
.locator('affine-editor-container')
|
|
.first()
|
|
.evaluate(() =>
|
|
window
|
|
.getComputedStyle(document.documentElement)
|
|
.getPropertyValue('--affine-theme-mode')
|
|
.trim()
|
|
);
|
|
return mode;
|
|
}
|
|
|
|
export async function getCurrentThemeCSSPropertyValue(
|
|
page: Page,
|
|
property: string
|
|
) {
|
|
const value = await page
|
|
.locator('affine-editor-container')
|
|
.evaluate(
|
|
(_, property) =>
|
|
window
|
|
.getComputedStyle(document.documentElement)
|
|
.getPropertyValue(property)
|
|
.trim(),
|
|
property
|
|
);
|
|
return value;
|
|
}
|
|
|
|
export async function scrollToTop(page: Page) {
|
|
await page.mouse.wheel(0, -1000);
|
|
|
|
await page.waitForFunction(() => {
|
|
const scrollContainer = document.querySelector('.affine-page-viewport');
|
|
if (!scrollContainer) {
|
|
throw new Error("Can't find scroll container");
|
|
}
|
|
return scrollContainer.scrollTop < 10;
|
|
});
|
|
}
|
|
|
|
export async function scrollToBottom(page: Page) {
|
|
// await page.mouse.wheel(0, 1000);
|
|
|
|
await page
|
|
.locator('.affine-page-viewport')
|
|
.evaluate(node =>
|
|
node.scrollTo({ left: 0, top: 1000, behavior: 'smooth' })
|
|
);
|
|
// TODO switch to `scrollend`
|
|
// See https://developer.chrome.com/en/blog/scrollend-a-new-javascript-event/
|
|
await page.waitForFunction(() => {
|
|
const scrollContainer = document.querySelector('.affine-page-viewport');
|
|
if (!scrollContainer) {
|
|
throw new Error("Can't find scroll container");
|
|
}
|
|
|
|
return (
|
|
// Wait for scrolled to the bottom
|
|
// Refer to https://stackoverflow.com/questions/3898130/check-if-a-user-has-scrolled-to-the-bottom-not-just-the-window-but-any-element
|
|
Math.abs(
|
|
scrollContainer.scrollHeight -
|
|
scrollContainer.scrollTop -
|
|
scrollContainer.clientHeight
|
|
) < 10
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function mockParseDocUrlService(
|
|
page: Page,
|
|
mapping: Record<string, string>
|
|
) {
|
|
await page.evaluate(mapping => {
|
|
const parseDocUrlService = window.host.std.get(
|
|
window.$blocksuite.blocks.ParseDocUrlProvider
|
|
);
|
|
parseDocUrlService.parseDocUrl = (url: string) => {
|
|
const docId = mapping[url];
|
|
if (docId) {
|
|
return { docId };
|
|
}
|
|
return;
|
|
};
|
|
}, mapping);
|
|
}
|