Files
AFFiNE-Mirror/blocksuite/tests-legacy/utils/asserts.ts
2024-12-20 11:08:21 +00:00

1345 lines
36 KiB
TypeScript

import './declare-test-window.js';
import type {
AffineInlineEditor,
NoteBlockModel,
RichText,
RootBlockModel,
} from '@blocks/index.js';
import {
DEFAULT_NOTE_HEIGHT,
DEFAULT_NOTE_WIDTH,
} from '@blocksuite/affine-model';
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import type { InlineRootElement } from '@inline/inline-editor.js';
import { expect, type Locator, type Page } from '@playwright/test';
import { COLLECTION_VERSION, PAGE_VERSION } from '@store/consts.js';
import type { BlockModel } from '@store/index.js';
import type { JSXElement } from '@store/utils/jsx.js';
import {
format as prettyFormat,
plugins as prettyFormatPlugins,
} from 'pretty-format';
import {
getCanvasElementsCount,
getConnectorPath,
getContainerChildIds,
getContainerIds,
getContainerOfElements,
getEdgelessElementBound,
getNoteRect,
getSelectedBound,
getSortedIdsInViewport,
getZoomLevel,
toIdCountMap,
toModelCoord,
} from './actions/edgeless.js';
import {
pressArrowLeft,
pressArrowRight,
pressBackspace,
redoByKeyboard,
SHORT_KEY,
type,
undoByKeyboard,
} from './actions/keyboard.js';
import {
captureHistory,
getClipboardCustomData,
getCurrentEditorDocId,
getCurrentThemeCSSPropertyValue,
getEditorLocator,
inlineEditorInnerTextToString,
} from './actions/misc.js';
import { getStringFromRichText } from './inline-editor.js';
import { currentEditorIndex } from './multiple-editor.js';
export { assertExists };
export const defaultStore = {
meta: {
pages: [
{
id: 'doc:home',
title: '',
tags: [],
},
],
blockVersions: {
'affine:paragraph': 1,
'affine:page': 2,
'affine:database': 3,
'affine:data-view': 1,
'affine:list': 1,
'affine:note': 1,
'affine:divider': 1,
'affine:embed-youtube': 1,
'affine:embed-figma': 1,
'affine:embed-github': 1,
'affine:embed-loom': 1,
'affine:embed-html': 1,
'affine:embed-linked-doc': 1,
'affine:embed-synced-doc': 1,
'affine:image': 1,
'affine:latex': 1,
'affine:frame': 1,
'affine:code': 1,
'affine:surface': 5,
'affine:bookmark': 1,
'affine:attachment': 1,
'affine:surface-ref': 1,
'affine:edgeless-text': 1,
},
workspaceVersion: COLLECTION_VERSION,
pageVersion: PAGE_VERSION,
},
spaces: {
'doc:home': {
blocks: {
'0': {
'prop:title': '',
'sys:id': '0',
'sys:flavour': 'affine:page',
'sys:children': ['1'],
'sys:version': 2,
},
'1': {
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:children': ['2'],
'sys:version': 1,
'prop:xywh': `[0,0,${DEFAULT_NOTE_WIDTH}, ${DEFAULT_NOTE_HEIGHT}]`,
'prop:background': '--affine-note-background-white',
'prop:index': 'a0',
'prop:hidden': false,
'prop:displayMode': 'both',
'prop:edgeless': {
style: {
borderRadius: 8,
borderSize: 4,
borderStyle: 'none',
shadowType: '--affine-note-shadow-box',
},
},
},
'2': {
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'sys:children': [],
'sys:version': 1,
'prop:text': 'hello',
'prop:type': 'text',
},
},
},
},
};
export type Bound = [x: number, y: number, w: number, h: number];
export async function assertEmpty(page: Page) {
await assertRichTexts(page, ['']);
}
export async function assertTitle(page: Page, text: string) {
const editor = getEditorLocator(page);
const inlineEditor = editor.locator('.doc-title-container').first();
const vText = inlineEditorInnerTextToString(await inlineEditor.innerText());
expect(vText).toBe(text);
}
export async function assertInlineEditorDeltas(
page: Page,
deltas: unknown[],
i = 0
) {
const actual = await page.evaluate(i => {
const inlineRoot = document.querySelectorAll<InlineRootElement>(
'[data-v-root="true"]'
)[i];
return inlineRoot.inlineEditor.yTextDeltas;
}, i);
expect(actual).toEqual(deltas);
}
export async function assertRichTextInlineDeltas(
page: Page,
deltas: unknown[],
i = 0
) {
const actual = await page.evaluate(
([i, currentEditorIndex]) => {
const editorHost =
document.querySelectorAll('editor-host')[currentEditorIndex];
const inlineRoot = editorHost.querySelectorAll<InlineRootElement>(
'rich-text [data-v-root="true"]'
)[i];
return inlineRoot.inlineEditor.yTextDeltas;
},
[i, currentEditorIndex]
);
expect(actual).toEqual(deltas);
}
export async function assertText(page: Page, text: string, i = 0) {
const actual = await getStringFromRichText(page, i);
expect(actual).toBe(text);
}
export async function assertTextContain(page: Page, text: string, i = 0) {
const actual = await getStringFromRichText(page, i);
expect(actual).toContain(text);
}
export async function assertRichTexts(page: Page, texts: string[]) {
const actualTexts = await page.evaluate(currentEditorIndex => {
const editorHost =
document.querySelectorAll('editor-host')[currentEditorIndex];
const richTexts = Array.from(
editorHost?.querySelectorAll<RichText>('rich-text') ?? []
);
return richTexts.map(richText => {
const editor = richText.inlineEditor as AffineInlineEditor;
return editor.yText.toString();
});
}, currentEditorIndex);
expect(actualTexts).toEqual(texts);
}
export async function assertEdgelessCanvasText(page: Page, text: string) {
const actualTexts = await page.evaluate(() => {
const editor = document.querySelector(
[
'edgeless-text-editor',
'edgeless-shape-text-editor',
'edgeless-frame-title-editor',
'edgeless-group-title-editor',
'edgeless-connector-label-editor',
].join(',')
);
if (!editor) {
throw new Error('editor not found');
}
// @ts-ignore
const inlineEditor = editor.inlineEditor;
return inlineEditor?.yText.toString();
});
expect(actualTexts).toEqual(text);
}
export async function assertRichImage(page: Page, count: number) {
const editor = getEditorLocator(page);
await expect(editor.locator('.resizable-img')).toHaveCount(count);
}
export async function assertDivider(page: Page, count: number) {
await expect(page.locator('affine-divider')).toHaveCount(count);
}
export async function assertRichDragButton(page: Page) {
await expect(page.locator('.resize')).toHaveCount(4);
}
export async function assertImageSize(
page: Page,
size: { width: number; height: number }
) {
const actual = await page.locator('.resizable-img').boundingBox();
expect(size).toEqual({
width: Math.floor(actual?.width ?? NaN),
height: Math.floor(actual?.height ?? NaN),
});
}
export async function assertImageOption(page: Page) {
// const actual = await page.locator('.embed-editing-state').count();
// expect(actual).toEqual(1);
const locator = page.locator('.affine-image-toolbar-container');
await expect(locator).toBeVisible();
}
export async function assertDocTitleFocus(page: Page) {
const locator = page.locator('doc-title .inline-editor').nth(0);
await expect(locator).toBeFocused();
}
export async function assertListPrefix(
page: Page,
predict: (string | RegExp)[],
range?: [number, number]
) {
const prefixs = page.locator('.affine-list-block__prefix');
let start = 0;
let end = await prefixs.count();
if (range) {
[start, end] = range;
}
for (let i = start; i < end; i++) {
const prefix = await prefixs.nth(i).innerText();
expect(prefix).toContain(predict[i]);
}
}
export async function assertBlockCount(
page: Page,
flavour: string,
count: number
) {
await expect(page.locator(`affine-${flavour}`)).toHaveCount(count);
}
export async function assertRowCount(page: Page, count: number) {
await expect(page.locator('.affine-database-block-row')).toHaveCount(count);
}
export async function assertVisibleBlockCount(
page: Page,
flavour: string,
count: number
) {
// not only count, but also check if all the blocks are visible
const locator = page.locator(`affine-${flavour}`);
let visibleCount = 0;
for (let i = 0; i < count; i++) {
if (await locator.nth(i).isVisible()) {
visibleCount++;
}
}
expect(visibleCount).toEqual(count);
}
export async function assertRichTextInlineRange(
page: Page,
richTextIndex: number,
rangeIndex: number,
rangeLength = 0
) {
const actual = await page.evaluate(
([richTextIndex, currentEditorIndex]) => {
const editorHost =
document.querySelectorAll('editor-host')[currentEditorIndex];
const richText = editorHost?.querySelectorAll('rich-text')[richTextIndex];
const inlineEditor = richText.inlineEditor;
return inlineEditor?.getInlineRange();
},
[richTextIndex, currentEditorIndex]
);
expect(actual).toEqual({ index: rangeIndex, length: rangeLength });
}
export async function assertNativeSelectionRangeCount(
page: Page,
count: number
) {
const actual = await page.evaluate(() => {
const selection = window.getSelection();
return selection?.rangeCount;
});
expect(actual).toEqual(count);
}
export async function assertNoteXYWH(
page: Page,
expected: [number, number, number, number]
) {
const actual = await page.evaluate(() => {
const rootModel = window.doc.root as RootBlockModel;
const note = rootModel.children.find(
x => x.flavour === 'affine:note'
) as NoteBlockModel;
return JSON.parse(note.xywh) as number[];
});
expect(actual[0]).toBeCloseTo(expected[0]);
expect(actual[1]).toBeCloseTo(expected[1]);
expect(actual[2]).toBeCloseTo(expected[2]);
expect(actual[3]).toBeCloseTo(expected[3]);
}
export async function assertTextFormat(
page: Page,
richTextIndex: number,
index: number,
resultObj: unknown
) {
const actual = await page.evaluate(
({ richTextIndex, index, currentEditorIndex }) => {
const editorHost =
document.querySelectorAll('editor-host')[currentEditorIndex];
const richText = editorHost.querySelectorAll('rich-text')[richTextIndex];
const inlineEditor = richText.inlineEditor;
if (!inlineEditor) {
throw new Error('Inline editor is undefined');
}
const result = inlineEditor.getFormat({
index,
length: 0,
});
return result;
},
{ richTextIndex, index, currentEditorIndex }
);
expect(actual).toEqual(resultObj);
}
export async function assertRichTextModelType(
page: Page,
type: string,
index = 0
) {
const actual = await page.evaluate(
({ index, BLOCK_ID_ATTR, currentEditorIndex }) => {
const editorHost =
document.querySelectorAll('editor-host')[currentEditorIndex];
const richText = editorHost.querySelectorAll('rich-text')[index];
const block = richText.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
if (!block) {
throw new Error('block component is undefined');
}
return (block.model as BlockModel<{ type: string }>).type;
},
{ index, BLOCK_ID_ATTR, currentEditorIndex }
);
expect(actual).toEqual(type);
}
export async function assertTextFormats(page: Page, resultObj: unknown[]) {
const actual = await page.evaluate(index => {
const editorHost = document.querySelectorAll('editor-host')[index];
const elements = editorHost.querySelectorAll('rich-text');
return Array.from(elements).map(el => {
const inlineEditor = el.inlineEditor;
if (!inlineEditor) {
throw new Error('Inline editor is undefined');
}
const result = inlineEditor.getFormat({
index: 0,
length: inlineEditor.yText.length,
});
return result;
});
}, currentEditorIndex);
expect(actual).toEqual(resultObj);
}
export async function assertStore(
page: Page,
expected: Record<string, unknown>
) {
const actual = await page.evaluate(() => {
const json = window.collection.doc.toJSON();
delete json.meta.pages[0].createDate;
return json;
});
expect(actual).toEqual(expected);
}
export async function assertBlockChildrenIds(
page: Page,
blockId: string,
ids: string[]
) {
const actual = await page.evaluate(
({ blockId }) => {
const element = document.querySelector(`[data-block-id="${blockId}"]`);
// @ts-ignore
const model = element.model as BlockModel;
return model.children.map(child => child.id);
},
{ blockId }
);
expect(actual).toEqual(ids);
}
export async function assertBlockChildrenFlavours(
page: Page,
blockId: string,
flavours: string[]
) {
const actual = await page.evaluate(
({ blockId }) => {
const element = document.querySelector(`[data-block-id="${blockId}"]`);
// @ts-ignore
const model = element.model as BlockModel;
return model.children.map(child => child.flavour);
},
{ blockId }
);
expect(actual).toEqual(flavours);
}
export async function assertParentBlockId(
page: Page,
blockId: string,
parentId: string
) {
const actual = await page.evaluate(
({ blockId }) => {
const model = window.doc?.getBlock(blockId)?.model;
if (!model) {
throw new Error(`Block with id ${blockId} not found`);
}
return model.doc.getParent(model)?.id;
},
{ blockId }
);
expect(actual).toEqual(parentId);
}
export async function assertParentBlockFlavour(
page: Page,
blockId: string,
flavour: string
) {
const actual = await page.evaluate(
({ blockId }) => {
const model = window.doc?.getBlock(blockId)?.model;
if (!model) {
throw new Error(`Block with id ${blockId} not found`);
}
return model.doc.getParent(model)?.flavour;
},
{ blockId }
);
expect(actual).toEqual(flavour);
}
export async function assertClassName(
page: Page,
selector: string,
className: RegExp
) {
const locator = page.locator(selector);
await expect(locator).toHaveClass(className);
}
export async function assertTextContent(
page: Page,
selector: string,
text: RegExp
) {
const locator = page.locator(selector);
await expect(locator).toHaveText(text);
}
export async function assertBlockType(
page: Page,
id: string | number | null,
type: string
) {
const actual = await page.evaluate(
({ id }) => {
const element = document.querySelector<BlockComponent>(
`[data-block-id="${id}"]`
);
if (!element) {
throw new Error(`Element with id ${id} not found`);
}
const model = element.model;
// @ts-ignore
return model.type;
},
{ id }
);
expect(actual).toBe(type);
}
export async function assertBlockFlavour(
page: Page,
id: string | number,
flavour: BlockSuite.Flavour
) {
const actual = await page.evaluate(
({ id }) => {
const element = document.querySelector<BlockComponent>(
`[data-block-id="${id}"]`
);
if (!element) {
throw new Error(`Element with id ${id} not found`);
}
const model = element.model;
return model.flavour;
},
{ id }
);
expect(actual).toBe(flavour);
}
export async function assertBlockTextContent(
page: Page,
id: string | number,
str: string
) {
const actual = await page.evaluate(
({ id }) => {
const element = document.querySelector<BlockComponent>(
`[data-block-id="${id}"]`
);
if (!element) {
throw new Error(`Element with id ${id} not found`);
}
const model = element.model;
return model.text?.toString() ?? '';
},
{ id }
);
expect(actual).toBe(str);
}
export async function assertBlockProps(
page: Page,
id: string,
props: Record<string, unknown>
) {
const actual = await page.evaluate(
([id, props]) => {
const element = document.querySelector(`[data-block-id="${id}"]`);
// @ts-ignore
const model = element.model as BlockModel;
return Object.fromEntries(
// @ts-ignore
Object.keys(props).map(key => [key, (model[key] as unknown).toString()])
);
},
[id, props] as const
);
expect(actual).toEqual(props);
}
export async function assertBlockTypes(page: Page, blockTypes: string[]) {
const actual = await page.evaluate(index => {
const editor = document.querySelectorAll('affine-editor-container')[index];
const elements = editor?.querySelectorAll('[data-block-id]');
return (
Array.from(elements)
.slice(2)
// @ts-ignore
.map(el => el.model.type)
);
}, currentEditorIndex);
expect(actual).toEqual(blockTypes);
}
/**
* @example
* ```ts
* await assertMatchMarkdown(
* page,
* `title
* text1
* text2`
* );
* ```
* @deprecated experimental, use {@link assertStoreMatchJSX} instead
*/
export async function assertMatchMarkdown(page: Page, text: string) {
const jsonDoc = (await page.evaluate(() =>
window.collection.doc.toJSON()
)) as Record<string, Record<string, unknown>>;
const titleNode = jsonDoc['doc:home']['0'] as Record<string, unknown>;
const markdownVisitor = (node: Record<string, unknown>): string => {
// TODO use schema
if (node['sys:flavour'] === 'affine:page') {
return (node['prop:title'] as Text).toString() ?? '';
}
if (!('prop:type' in node)) {
return '[? unknown node]';
}
if (node['prop:type'] === 'text') {
return node['prop:text'] as string;
}
if (node['prop:type'] === 'bulleted') {
return `- ${node['prop:text']}`;
}
// TODO please fix this
return `[? ${node['prop:type']} node]`;
};
const INDENT_SIZE = 2;
const visitNodes = (
node: Record<string, unknown>,
visitor: (node: Record<string, unknown>) => string
): string[] => {
if (!('sys:children' in node) || !Array.isArray(node['sys:children'])) {
throw new Error("Failed to visit nodes: 'sys:children' is not an array");
// return visitor(node);
}
const children = node['sys:children'].map(id => jsonDoc['doc:home'][id]);
return [
visitor(node),
...children.flatMap(child =>
visitNodes(child as Record<string, unknown>, visitor).map(line => {
if (node['sys:flavour'] === 'affine:page') {
// Ad hoc way to remove the title indent
return line;
}
return ' '.repeat(INDENT_SIZE) + line;
})
),
];
};
const visitRet = visitNodes(titleNode, markdownVisitor);
const actual = visitRet.join('\n');
expect(actual).toEqual(text);
}
export async function assertStoreMatchJSX(
page: Page,
snapshot: string,
blockId?: string
) {
const docId = await getCurrentEditorDocId(page);
const element = (await page.evaluate(
([blockId, docId]) => window.collection.exportJSX(blockId, docId),
[blockId, docId]
)) as JSXElement;
// Fix symbol can not be serialized, we need to set $$typeof manually
// If the function passed to the page.evaluate(pageFunction[, arg]) returns a non-Serializable value,
// then page.evaluate(pageFunction[, arg]) resolves to undefined.
// See https://playwright.dev/docs/api/class-page#page-evaluate
const testSymbol = Symbol.for('react.test.json');
const markSymbol = (node: JSXElement) => {
node.$$typeof = testSymbol;
if (!node.children) {
return;
}
const propText = node.props['prop:text'];
if (propText && typeof propText === 'object') {
markSymbol(propText);
}
node.children.forEach(child => {
if (!(typeof child === 'object')) {
return;
}
markSymbol(child);
});
};
markSymbol(element);
// See https://github.com/facebook/jest/blob/main/packages/pretty-format
const formattedJSX = prettyFormat(element, {
plugins: [prettyFormatPlugins.ReactTestComponent],
printFunctionName: false,
});
expect(formattedJSX, formattedJSX).toEqual(snapshot.trimStart());
}
type MimeType = 'text/plain' | 'blocksuite/x-c+w' | 'text/html';
export function assertClipItems(_page: Page, _key: MimeType, _value: unknown) {
// FIXME: use original clipboard API
// const clipItems = await page.evaluate(() => {
// return document
// .getElementsByTagName('affine-editor-container')[0]
// .clipboard['_copy']['_getClipItems']();
// });
// const actual = clipItems.find(item => item.mimeType === key)?.data;
// expect(actual).toEqual(value);
return true;
}
export function assertAlmostEqual(
actual: number,
expected: number,
precision = 0.001
) {
expect(
Math.abs(actual - expected),
`expected: ${expected}, but actual: ${actual}`
).toBeLessThan(precision);
}
export function assertPointAlmostEqual(
actual: number[],
expected: number[],
precision = 0.001
) {
assertAlmostEqual(actual[0], expected[0], precision);
assertAlmostEqual(actual[1], expected[1], precision);
}
/**
* Assert the locator is visible in the viewport.
* It will check the bounding box of the locator is within the viewport.
*
* See also https://playwright.dev/docs/actionability#visible
*/
export async function assertLocatorVisible(
page: Page,
locator: Locator,
visible = true
) {
const bodyRect = await page.locator('body').boundingBox();
const rect = await locator.boundingBox();
expect(rect).toBeTruthy();
expect(bodyRect).toBeTruthy();
if (!rect || !bodyRect) {
throw new Error('Unreachable');
}
if (visible) {
// Assert the locator is **fully** visible
await expect(locator).toBeVisible();
expect(rect.x).toBeGreaterThanOrEqual(0);
expect(rect.y).toBeGreaterThanOrEqual(0);
expect(rect.x + rect.width).toBeLessThanOrEqual(
bodyRect.x + bodyRect.width
);
expect(rect.y + rect.height).toBeLessThanOrEqual(
bodyRect.x + bodyRect.height
);
} else {
// Assert the locator is **fully** invisible
const locatorIsVisible = await locator.isVisible();
if (!locatorIsVisible) {
// If the locator is invisible, we don't need to check the bounding box
return;
}
const isInVisible =
rect.x > bodyRect.x + bodyRect.width ||
rect.y > bodyRect.y + bodyRect.height ||
rect.x + rect.width < bodyRect.x ||
rect.y + rect.height < bodyRect.y;
expect(isInVisible).toBe(true);
}
}
/**
* Assert basic keyboard operation works in input
*
* NOTICE:
* - it will clear the input value.
* - it will pollute undo/redo history.
*/
export async function assertKeyboardWorkInInput(page: Page, locator: Locator) {
await expect(locator).toBeVisible();
await locator.focus();
// Clear input before test
await locator.clear();
// type/backspace
await type(page, '12/34');
await expect(locator).toHaveValue('12/34');
await captureHistory(page);
await pressBackspace(page);
await expect(locator).toHaveValue('12/3');
// undo/redo
await undoByKeyboard(page);
await expect(locator).toHaveValue('12/34');
await redoByKeyboard(page);
await expect(locator).toHaveValue('12/3');
// keyboard
await pressArrowLeft(page, 2);
await pressArrowRight(page, 1);
await pressBackspace(page);
await expect(locator).toHaveValue('123');
await pressBackspace(page);
await expect(locator).toHaveValue('13');
// copy/cut/paste
await page.keyboard.press(`${SHORT_KEY}+a`, { delay: 50 });
await page.keyboard.press(`${SHORT_KEY}+c`, { delay: 50 });
await pressBackspace(page);
await expect(locator).toHaveValue('');
await page.keyboard.press(`${SHORT_KEY}+v`, { delay: 50 });
await expect(locator).toHaveValue('13');
await page.keyboard.press(`${SHORT_KEY}+a`, { delay: 50 });
await page.keyboard.press(`${SHORT_KEY}+x`, { delay: 50 });
await expect(locator).toHaveValue('');
}
export function assertSameColor(c1?: `#${string}`, c2?: `#${string}`) {
expect(c1?.toLowerCase()).toEqual(c2?.toLowerCase());
}
type Rect = { x: number; y: number; w: number; h: number };
export async function assertNoteRectEqual(
page: Page,
noteId: string,
expected: Rect
) {
const rect = await getNoteRect(page, noteId);
assertRectEqual(rect, expected);
}
export function assertRectEqual(a: Rect, b: Rect) {
expect(a.x).toBeCloseTo(b.x, 0);
expect(a.y).toBeCloseTo(b.y, 0);
expect(a.w).toBeCloseTo(b.w, 0);
expect(a.h).toBeCloseTo(b.h, 0);
}
export function assertDOMRectEqual(a: DOMRect, b: DOMRect) {
expect(a.x).toBeCloseTo(b.x, 0);
expect(a.y).toBeCloseTo(b.y, 0);
expect(a.width).toBeCloseTo(b.width, 0);
expect(a.height).toBeCloseTo(b.height, 0);
}
export async function assertEdgelessDraggingArea(page: Page, xywh: number[]) {
const [x, y, w, h] = xywh;
const editor = getEditorLocator(page);
const draggingArea = editor
.locator('edgeless-dragging-area-rect')
.locator('.affine-edgeless-dragging-area');
const box = await draggingArea.boundingBox();
if (!box) throw new Error('Missing edgeless dragging area');
expect(box.x).toBeCloseTo(x, 0);
expect(box.y).toBeCloseTo(y, 0);
expect(box.width).toBeCloseTo(w, 0);
expect(box.height).toBeCloseTo(h, 0);
}
export async function getSelectedRect(page: Page) {
const editor = getEditorLocator(page);
const selectedRect = editor
.locator('edgeless-selected-rect')
.locator('.affine-edgeless-selected-rect');
// FIXME: remove this timeout
await page.waitForTimeout(50);
const box = await selectedRect.boundingBox();
if (!box) throw new Error('Missing edgeless selected rect');
return box;
}
// Better to use xxSelectedModelRect
export async function assertEdgelessSelectedRect(page: Page, xywh: number[]) {
const [x, y, w, h] = xywh;
const box = await getSelectedRect(page);
expect(box.x).toBeCloseTo(x, 0);
expect(box.y).toBeCloseTo(y, 0);
expect(box.width).toBeCloseTo(w, 0);
expect(box.height).toBeCloseTo(h, 0);
}
export async function assertEdgelessSelectedModelRect(
page: Page,
xywh: number[]
) {
const [x, y, w, h] = xywh;
const box = await getSelectedRect(page);
const [mX, mY] = await toModelCoord(page, [box.x, box.y]);
expect(mX).toBeCloseTo(x, 0);
expect(mY).toBeCloseTo(y, 0);
expect(box.width).toBeCloseTo(w, 0);
expect(box.height).toBeCloseTo(h, 0);
}
export async function assertEdgelessSelectedElementHandleCount(
page: Page,
count: number
) {
const editor = getEditorLocator(page);
const handles = editor.locator('.element-handle');
await expect(handles).toHaveCount(count);
}
// Better to use xxSelectedModelRect
export async function assertEdgelessRemoteSelectedRect(
page: Page,
xywh: number[],
index = 0
) {
const [x, y, w, h] = xywh;
const editor = getEditorLocator(page);
const remoteSelectedRect = editor
.locator('affine-edgeless-remote-selection-widget')
.locator('.remote-rect')
.nth(index);
const box = await remoteSelectedRect.boundingBox();
if (!box) throw new Error('Missing edgeless remote selected rect');
expect(box.x).toBeCloseTo(x, 0);
expect(box.y).toBeCloseTo(y, 0);
expect(box.width).toBeCloseTo(w, 0);
expect(box.height).toBeCloseTo(h, 0);
}
export async function assertEdgelessRemoteSelectedModelRect(
page: Page,
xywh: number[],
index = 0
) {
const [x, y, w, h] = xywh;
const editor = getEditorLocator(page);
const remoteSelectedRect = editor
.locator('affine-edgeless-remote-selection-widget')
.locator('.remote-rect')
.nth(index);
const box = await remoteSelectedRect.boundingBox();
if (!box) throw new Error('Missing edgeless remote selected rect');
const [mX, mY] = await toModelCoord(page, [box.x, box.y]);
expect(mX).toBeCloseTo(x, 0);
expect(mY).toBeCloseTo(y, 0);
expect(box.width).toBeCloseTo(w, 0);
expect(box.height).toBeCloseTo(h, 0);
}
export async function assertEdgelessSelectedRectRotation(page: Page, deg = 0) {
const editor = getEditorLocator(page);
const selectedRect = editor
.locator('edgeless-selected-rect')
.locator('.affine-edgeless-selected-rect');
const transform = await selectedRect.evaluate(el => el.style.transform);
const r = new RegExp(`rotate\\(${deg}deg\\)`);
expect(transform).toMatch(r);
}
export async function assertEdgelessSelectedReactCursor(
page: Page,
expected: (
| {
mode: 'resize';
handle:
| 'top'
| 'right'
| 'bottom'
| 'left'
| 'top-left'
| 'top-right'
| 'bottom-right'
| 'bottom-left';
}
| {
mode: 'rotate';
handle: 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left';
}
) & {
cursor: string;
}
) {
const editor = getEditorLocator(page);
const selectedRect = editor
.locator('edgeless-selected-rect')
.locator('.affine-edgeless-selected-rect');
const handle = selectedRect
.getByLabel(expected.handle, { exact: true })
.locator(`.${expected.mode}`);
await handle.hover();
await expect(handle).toHaveCSS('cursor', expected.cursor);
}
export async function assertEdgelessNonSelectedRect(page: Page) {
const rect = page.locator('edgeless-selected-rect');
await expect(rect).toBeHidden();
}
export async function assertSelectionInNote(
page: Page,
noteId: string,
blockNote: string = 'affine-note'
) {
const closestNoteId = await page.evaluate(blockNote => {
const selection = window.getSelection();
const note = selection?.anchorNode?.parentElement?.closest(blockNote);
return note?.getAttribute('data-block-id');
}, blockNote);
expect(closestNoteId).toEqual(noteId);
}
export async function assertEdgelessNoteBackground(
page: Page,
noteId: string,
color: string
) {
const editor = getEditorLocator(page);
const backgroundColor = await editor
.locator(`affine-edgeless-note[data-block-id="${noteId}"]`)
.evaluate(ele => {
const noteWrapper =
ele?.querySelector<HTMLDivElement>('.note-background');
if (!noteWrapper) {
throw new Error(`Could not find note: ${noteId}`);
}
return noteWrapper.style.backgroundColor;
});
expect(backgroundColor).toEqual(`var(${color})`);
}
function toHex(color: string) {
let r, g, b;
if (color.startsWith('#')) {
color = color.substr(1);
if (color.length === 3) {
color = color.replace(/./g, '$&$&');
}
[r, g, b] = color.match(/.{2}/g)?.map(hex => parseInt(hex, 16)) ?? [];
} else if (color.startsWith('rgba')) {
[r, g, b] = color.match(/\d+/g)?.map(Number) ?? [];
} else if (color.startsWith('rgb')) {
[r, g, b] = color.match(/\d+/g)?.map(Number) ?? [];
} else {
throw new Error('Invalid color format');
}
if (r === undefined || g === undefined || b === undefined) {
throw new Error('Invalid color format');
}
const hex = ((r << 16) | (g << 8) | b).toString(16);
return '#' + '0'.repeat(6 - hex.length) + hex;
}
export async function assertEdgelessColorSameWithHexColor(
page: Page,
edgelessColor: string,
hexColor: `#${string}`
) {
const themeColor = await getCurrentThemeCSSPropertyValue(page, edgelessColor);
expect(themeColor).toBeTruthy();
const edgelessHexColor = toHex(themeColor);
assertSameColor(hexColor, edgelessHexColor as `#${string}`);
}
export async function assertZoomLevel(page: Page, zoom: number) {
const z = await getZoomLevel(page);
expect(z).toBe(Math.ceil(zoom));
}
export async function assertConnectorPath(
page: Page,
path: number[][],
index = 0
) {
const actualPath = await getConnectorPath(page, index);
actualPath.every((p, i) => assertPointAlmostEqual(p, path[i], 0.1));
}
export function assertRectExist(
rect: { x: number; y: number; width: number; height: number } | null
): asserts rect is { x: number; y: number; width: number; height: number } {
expect(rect).not.toBe(null);
}
export async function assertEdgelessElementBound(
page: Page,
elementId: string,
bound: Bound
) {
const actual = await getEdgelessElementBound(page, elementId);
assertBound(actual, bound);
}
export async function assertSelectedBound(
page: Page,
expected: Bound,
index = 0
) {
const bound = await getSelectedBound(page, index);
assertBound(bound, expected);
}
/**
* asserts all groups and they children count at the same time
* @param page
* @param expected the expected group id and the count of of its children
*/
export async function assertContainerIds(
page: Page,
expected: Record<string, number>
) {
const ids = await getContainerIds(page);
const result = toIdCountMap(ids);
expect(result).toEqual(expected);
}
export async function assertSortedIds(page: Page, expected: string[]) {
const ids = await getSortedIdsInViewport(page);
expect(ids).toEqual(expected);
}
export async function assertContainerChildIds(
page: Page,
expected: Record<string, number>,
id: string
) {
const ids = await getContainerChildIds(page, id);
const result = toIdCountMap(ids);
expect(result).toEqual(expected);
}
export async function assertContainerOfElements(
page: Page,
elements: string[],
containerId: string | null
) {
const elementContainers = await getContainerOfElements(page, elements);
elementContainers.forEach(elementContainer => {
expect(elementContainer).toEqual(containerId);
});
}
/**
* Assert the given container has the expected children count.
* And the children's container id should equal to the given container id.
* @param page
* @param containerId
* @param childrenCount
*/
export async function assertContainerChildCount(
page: Page,
containerId: string,
childrenCount: number
) {
const ids = await getContainerChildIds(page, containerId);
await assertContainerOfElements(page, ids, containerId);
expect(new Set(ids).size).toBe(childrenCount);
}
export async function assertCanvasElementsCount(page: Page, expected: number) {
const number = await getCanvasElementsCount(page);
expect(number).toEqual(expected);
}
export function assertBound(received: Bound, expected: Bound) {
expect(received[0]).toBeCloseTo(expected[0], 0);
expect(received[1]).toBeCloseTo(expected[1], 0);
expect(received[2]).toBeCloseTo(expected[2], 0);
expect(received[3]).toBeCloseTo(expected[3], 0);
}
export async function assertClipboardItem(
page: Page,
data: unknown,
type: string
) {
type Args = [type: string];
const dataInClipboard = await page.evaluate(
async ([type]: Args) => {
const clipItems = await navigator.clipboard.read();
const item = clipItems.find(item => item.types.includes(type));
const data = await item?.getType(type);
return data?.text();
},
[type] as Args
);
expect(dataInClipboard).toBe(data);
}
export async function assertClipboardCustomData(
page: Page,
type: string,
data: unknown
) {
const dataInClipboard = await getClipboardCustomData(page, type);
expect(dataInClipboard).toBe(data);
}
export function assertClipData(
clipItems: { mimeType: string; data: unknown }[],
expectClipItems: { mimeType: string; data: unknown }[],
type: string
) {
expect(clipItems.find(item => item.mimeType === type)?.data).toBe(
expectClipItems.find(item => item.mimeType === type)?.data
);
}
export async function assertHasClass(locator: Locator, className: string) {
expect(
(await locator.getAttribute('class'))?.split(' ').includes(className)
).toEqual(true);
}
export async function assertNotHasClass(locator: Locator, className: string) {
expect(
(await locator.getAttribute('class'))?.split(' ').includes(className)
).toEqual(false);
}
export async function assertNoteSequence(page: Page, expected: string) {
const actual = await page.locator('.page-visible-index-label').innerText();
expect(expected).toBe(actual);
}
export async function assertBlockSelections(page: Page, paths: string[]) {
const selections = await page.evaluate(() => {
const host = document.querySelector<EditorHost>('editor-host');
if (!host) {
throw new Error('editor-host host not found');
}
return host.selection.filter('block');
});
const actualPaths = selections.map(selection => selection.blockId);
expect(actualPaths).toEqual(paths);
}
export async function assertTextSelection(
page: Page,
from?: {
blockId: string;
index: number;
length: number;
},
to?: {
blockId: string;
index: number;
length: number;
}
) {
const selection = await page.evaluate(() => {
const host = document.querySelector<EditorHost>('editor-host');
if (!host) {
throw new Error('editor-host host not found');
}
return host.selection.find('text');
});
if (!from && !to) {
expect(selection).toBeUndefined();
return;
}
if (from) {
expect(selection?.from).toEqual(from);
}
if (to) {
expect(selection?.to).toEqual(to);
}
}
export async function assertConnectorStrokeColor(page: Page, color: string) {
const colorButton = page
.locator('edgeless-change-connector-button')
.locator('edgeless-color-panel')
.locator(`.color-unit[aria-label="${color}"]`);
expect(await colorButton.count()).toBe(1);
}