import type { RichTextCell, RichTextCellEditing, } from '@blocksuite/affine/blocks/database'; import { ZERO_WIDTH_SPACE } from '@blocksuite/affine/inline'; import { expect, type Locator, type Page } from '@playwright/test'; import { pressEnter, selectAllByKeyboard, type, } from '../utils/actions/keyboard.js'; import { getBoundingBox, getBoundingClientRect, getEditorLocator, waitNextFrame, } from '../utils/actions/misc.js'; export async function press(page: Page, content: string) { await page.keyboard.press(content, { delay: 50 }); await page.waitForTimeout(50); } export async function initDatabaseColumn(page: Page, title = '') { const editor = getEditorLocator(page); await editor.locator('affine-data-view-table-group').first().hover(); const columnAddBtn = editor.locator('.header-add-column-button'); await columnAddBtn.click(); await waitNextFrame(page, 200); if (title) { await selectAllByKeyboard(page); await type(page, title); await waitNextFrame(page); await pressEnter(page); } else { await pressEnter(page); } } export const renameColumn = async (page: Page, name: string) => { const column = page.locator('affine-database-header-column', { hasText: name, }); await column.click(); }; export async function performColumnAction( page: Page, name: string, action: string ) { await renameColumn(page, name); const actionMenu = page.locator(`.affine-menu-button`, { hasText: action }); await actionMenu.click(); } export async function switchColumnType( page: Page, columnType: string, columnIndex = 1 ) { const { typeIcon } = await getDatabaseHeaderColumn(page, columnIndex); await typeIcon.click(); await clickColumnType(page, columnType); } export function clickColumnType(page: Page, columnType: string) { const typeMenu = page.locator(`.affine-menu-button`, { hasText: new RegExp(`${columnType}`), }); return typeMenu.click(); } export function getDatabaseBodyRows(page: Page) { const rowContainer = page.locator('.affine-database-block-rows'); return rowContainer.locator('.database-row'); } export function getDatabaseBodyRow(page: Page, rowIndex = 0) { const rows = getDatabaseBodyRows(page); return rows.nth(rowIndex); } export function getDatabaseTableContainer(page: Page) { const container = page.locator('.affine-database-table-container'); return container; } export async function assertDatabaseTitleColumnText( page: Page, title: string, index = 0 ) { const text = await page.evaluate(index => { const rowContainer = document.querySelector('.affine-database-block-rows'); const row = rowContainer?.querySelector( `.database-row:nth-child(${index + 1})` ); const titleColumnCell = row?.querySelector('.database-cell:nth-child(1)'); const titleSpan = titleColumnCell?.querySelector( '.data-view-header-area-rich-text' ) as HTMLElement; if (!titleSpan) throw new Error('Cannot find database title column editor'); return titleSpan.innerText; }, index); if (title === '') { expect(text).toMatch(new RegExp(`^(|[${ZERO_WIDTH_SPACE}])$`)); } else { expect(text).toBe(title); } } export function getDatabaseBodyCell( page: Page, { rowIndex, columnIndex, }: { rowIndex: number; columnIndex: number; } ) { const row = getDatabaseBodyRow(page, rowIndex); const cell = row.locator('.database-cell').nth(columnIndex); return cell; } export function getDatabaseBodyCellContent( page: Page, { rowIndex, columnIndex, cellClass, }: { rowIndex: number; columnIndex: number; cellClass: string; } ) { const cell = getDatabaseBodyCell(page, { rowIndex, columnIndex }); const cellContent = cell.locator(`.${cellClass}`); return cellContent; } export function getFirstColumnCell(page: Page, cellClass: string) { const cellContent = getDatabaseBodyCellContent(page, { rowIndex: 0, columnIndex: 1, cellClass, }); return cellContent; } export async function clickSelectOption(page: Page, index = 0) { await page.locator('.select-option-icon').nth(index).click(); } export async function performSelectColumnTagAction( page: Page, name: string, index = 0 ) { await clickSelectOption(page, index); await page .locator('.affine-menu-button', { hasText: new RegExp(name) }) .click(); } export async function assertSelectedStyle( page: Page, key: keyof CSSStyleDeclaration, value: string ) { const style = await getElementStyle(page, '.select-selected', key); expect(style).toBe(value); } export async function clickDatabaseOutside(page: Page) { const docTitle = page.locator('.doc-title-container'); await docTitle.click(); } export async function assertColumnWidth(locator: Locator, width: number) { const box = await getBoundingBox(locator); expect(box.width).toBe(width + 1); return box; } export async function assertDatabaseCellRichTexts( page: Page, { rowIndex = 0, columnIndex = 1, text, }: { rowIndex?: number; columnIndex?: number; text: string; } ) { const cellContainer = page.locator( `affine-database-cell-container[data-row-index='${rowIndex}'][data-column-index='${columnIndex}']` ); const cellEditing = cellContainer.locator( 'affine-database-rich-text-cell-editing' ); const cell = cellContainer.locator('affine-database-rich-text-cell'); const richText = (await cellEditing.count()) === 0 ? cell : cellEditing; const actualTexts = await richText.evaluate(ele => { return (ele as RichTextCellEditing).inlineEditor?.yTextString; }); expect(actualTexts).toEqual(text); } export async function assertDatabaseCellNumber( page: Page, { rowIndex = 0, columnIndex = 1, text, }: { rowIndex?: number; columnIndex?: number; text: string; } ) { const actualText = await page .locator('.affine-database-block-rows') .locator('.database-row') .nth(rowIndex) .locator('.database-cell') .nth(columnIndex) .locator('.number') .textContent(); expect(actualText?.trim()).toEqual(text); } export async function assertDatabaseCellLink( page: Page, { rowIndex = 0, columnIndex = 1, text, }: { rowIndex?: number; columnIndex?: number; text: string; } ) { const actualTexts = await page.evaluate( ({ rowIndex, columnIndex }) => { const rows = document.querySelector('.affine-database-block-rows'); const row = rows?.querySelector( `.database-row:nth-child(${rowIndex + 1})` ); const cell = row?.querySelector( `.database-cell:nth-child(${columnIndex + 1})` ); const richText = cell?.querySelector('affine-database-link-cell') ?? cell?.querySelector( 'affine-database-link-cell-editing' ); if (!richText) throw new Error('Missing database rich text cell'); return richText.inlineEditor!.yText.toString(); }, { rowIndex, columnIndex } ); expect(actualTexts).toEqual(text); } export async function assertDatabaseTitleText(page: Page, text: string) { const dbTitle = page.locator('[data-block-is-database-title="true"]'); expect(await dbTitle.inputValue()).toEqual(text); } export async function waitSearchTransitionEnd(page: Page) { await waitNextFrame(page, 400); } export async function assertDatabaseSearching( page: Page, isSearching: boolean ) { const searchExpand = page.locator('.search-container-expand'); const count = await searchExpand.count(); expect(count).toBe(isSearching ? 1 : 0); } export async function focusDatabaseSearch(page: Page) { await (await getDatabaseMouse(page)).mouseOver(); const searchExpand = page.locator('.search-container-expand'); const count = await searchExpand.count(); if (count === 1) { const input = page.locator('.affine-database-search-input'); await input.click(); } else { const searchIcon = page.locator('.affine-database-search-input-icon'); await searchIcon.click(); await waitSearchTransitionEnd(page); } } export async function blurDatabaseSearch(page: Page) { const dbTitle = page.locator('[data-block-is-database-title="true"]'); await dbTitle.click(); } export async function focusDatabaseHeader(page: Page, columnIndex = 0) { const column = page.locator('.affine-database-column').nth(columnIndex); const box = await getBoundingBox(column); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await waitNextFrame(page); return column; } export async function getDatabaseMouse(page: Page) { const databaseRect = await getBoundingClientRect( page, '.affine-database-table' ); return { mouseOver: async () => { await page.mouse.move(databaseRect.x, databaseRect.y); }, mouseLeave: async () => { await page.mouse.move(databaseRect.x - 1, databaseRect.y - 1); }, }; } export async function getDatabaseHeaderColumn(page: Page, index = 0) { const column = page.locator('.affine-database-column').nth(index); const box = await getBoundingBox(column); const textElement = column.locator('.affine-database-column-text-input'); const text = await textElement.innerText(); const typeIcon = column.locator('.affine-database-column-type-icon'); return { column, box, text, textElement, typeIcon, }; } export async function assertRowsSelection( page: Page, rowIndexes: [start: number, end: number] ) { const rows = page.locator('data-view-table-row'); const startIndex = rowIndexes[0]; const endIndex = rowIndexes[1]; for (let i = startIndex; i <= endIndex; i++) { const row = rows.nth(i); await row.locator('.row-select-checkbox .selected').isVisible(); } } export async function assertCellsSelection( page: Page, cellIndexes: { start: [rowIndex: number, columnIndex: number]; end?: [rowIndex: number, columnIndex: number]; } ) { const { start, end } = cellIndexes; if (!end) { // single cell const focus = page.locator('.database-focus'); const focusBox = await getBoundingBox(focus); const [rowIndex, columnIndex] = start; const cell = getDatabaseBodyCell(page, { rowIndex, columnIndex }); const cellBox = await getBoundingBox(cell); expect(focusBox).toEqual({ x: cellBox.x, y: cellBox.y - 1, height: cellBox.height + 2, width: cellBox.width + 1, }); } else { // multi cells const selection = page.locator('.database-selection'); const selectionBox = await getBoundingBox(selection); const [startRowIndex, startColumnIndex] = start; const [endRowIndex, endColumnIndex] = end; const rowIndexStart = Math.min(startRowIndex, endRowIndex); const rowIndexEnd = Math.max(startRowIndex, endRowIndex); const columnIndexStart = Math.min(startColumnIndex, endColumnIndex); const columnIndexEnd = Math.max(startColumnIndex, endColumnIndex); let height = 0; let width = 0; let x = 0; let y = 0; for (let i = rowIndexStart; i <= rowIndexEnd; i++) { const cell = getDatabaseBodyCell(page, { rowIndex: i, columnIndex: columnIndexStart, }); const box = await getBoundingBox(cell); height += box.height + 1; if (i === rowIndexStart) { y = box.y; } } for (let j = columnIndexStart; j <= columnIndexEnd; j++) { const cell = getDatabaseBodyCell(page, { rowIndex: rowIndexStart, columnIndex: j, }); const box = await getBoundingBox(cell); width += box.width; if (j === columnIndexStart) { x = box.x; } } expect(selectionBox).toEqual({ x, y, height, width: width + 1, }); } } export async function getElementStyle( page: Page, selector: string, key: keyof CSSStyleDeclaration ) { const style = await page.evaluate( ({ key, selector }) => { const el = document.querySelector(selector); if (!el) throw new Error(`Missing ${selector} tag`); // @ts-ignore return el.style[key]; }, { key, selector, } ); return style; } export async function focusKanbanCardHeader(page: Page, index = 0) { const cardHeader = page.locator('data-view-header-area-text').nth(index); await cardHeader.click(); } export async function clickKanbanCardHeader(page: Page, index = 0) { const cardHeader = page.locator('data-view-header-area-text').nth(index); await cardHeader.click(); await cardHeader.click(); } export async function assertKanbanCardHeaderText( page: Page, text: string, index = 0 ) { const cardHeader = page.locator('data-view-header-area-text').nth(index); await expect(cardHeader).toHaveText(text); } export async function assertKanbanCellSelected( page: Page, { groupIndex, cardIndex, cellIndex, }: { groupIndex: number; cardIndex: number; cellIndex: number; } ) { const border = await page.evaluate( ({ groupIndex, cardIndex, cellIndex }) => { const group = document.querySelector( `affine-data-view-kanban-group:nth-child(${groupIndex + 1})` ); const card = group?.querySelector( `affine-data-view-kanban-card:nth-child(${cardIndex + 1})` ); const cells = Array.from( card?.querySelectorAll(`affine-data-view-kanban-cell`) ?? [] ); const cell = cells[cellIndex]; if (!cell) throw new Error(`Missing cell tag`); return cell.style.border; }, { groupIndex, cardIndex, cellIndex, } ); expect(border).toEqual('1px solid var(--affine-primary-color)'); } export async function assertKanbanCardSelected( page: Page, { groupIndex, cardIndex, }: { groupIndex: number; cardIndex: number; } ) { const border = await page.evaluate( ({ groupIndex, cardIndex }) => { const group = document.querySelector( `affine-data-view-kanban-group:nth-child(${groupIndex + 1})` ); const card = group?.querySelector( `affine-data-view-kanban-card:nth-child(${cardIndex + 1})` ); if (!card) throw new Error(`Missing card tag`); return card.style.border; }, { groupIndex, cardIndex, } ); expect(border).toEqual('1px solid var(--affine-primary-color)'); } export function getKanbanCard( page: Page, { groupIndex, cardIndex, }: { groupIndex: number; cardIndex: number; } ) { const group = page.locator('affine-data-view-kanban-group').nth(groupIndex); const card = group.locator('affine-data-view-kanban-card').nth(cardIndex); return card; } export const moveToCenterOf = async (page: Page, locator: Locator) => { const box = (await locator.boundingBox())!; expect(box).toBeDefined(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); }; export const changeColumnType = async ( page: Page, column: number, name: string ) => { await waitNextFrame(page); await page.locator('affine-database-header-column').nth(column).click(); await waitNextFrame(page, 200); await pressKey(page, 'Escape'); await pressKey(page, 'ArrowDown'); await pressKey(page, 'Enter'); await type(page, name); await pressKey(page, 'ArrowDown'); await pressKey(page, 'Enter'); }; export const pressKey = async (page: Page, key: string, count: number = 1) => { for (let i = 0; i < count; i++) { await waitNextFrame(page); await press(page, key); } await waitNextFrame(page); };