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( '[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( '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('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(`[${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 ) { 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( `[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( `[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( `[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 ) { 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>; const titleNode = jsonDoc['doc:home']['0'] as Record; const markdownVisitor = (node: Record): 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, visitor: (node: Record) => 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, 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('.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 ) { 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, 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('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('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); }