hololake-platform/tests/smoke/editor-block-reorder.spec.ts

314 lines
12 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import { test, expect, type Locator, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
async function blockOuterForText(page: Page, text: string): Promise<Locator> {
const textNode = page.locator('.bn-editor').getByText(text, { exact: true }).first()
await expect(textNode).toBeVisible({ timeout: 5_000 })
return textNode.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " bn-block-outer ")][1]')
}
async function visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
await block.hover()
const sectionControl = page.locator('.bn-side-menu button:has([data-test="headingCollapseToggle"])').first()
const handle = page.locator('.bn-side-menu button:has([data-test="dragHandle"])').first()
await expect(sectionControl).toBeVisible({ timeout: 5_000 })
await expect(handle).toBeVisible({ timeout: 5_000 })
await expect(handle).not.toHaveAttribute('draggable', 'true')
const controlBox = await sectionControl.boundingBox()
const handleBox = await handle.boundingBox()
expect(controlBox).not.toBeNull()
expect(handleBox).not.toBeNull()
expect(handleBox!.x).toBeLessThan(controlBox!.x)
expect(Math.abs((controlBox!.y + controlBox!.height / 2) - (handleBox!.y + handleBox!.height / 2))).toBeLessThanOrEqual(2)
return handle
}
async function expectSideMenuCenteredOnText(page: Page, text: string): Promise<void> {
const block = await blockOuterForText(page, text)
await block.hover()
await expect(page.locator('.bn-side-menu')).toBeVisible({ timeout: 5_000 })
const delta = await block.evaluate((blockElement) => {
const content = blockElement.querySelector('.bn-block-content')
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
const sideMenu = document.querySelector('.bn-side-menu')
if (!inlineContent || !sideMenu) return Number.POSITIVE_INFINITY
const range = document.createRange()
range.selectNodeContents(inlineContent)
const textRect = Array.from(range.getClientRects())
.find((rect) => rect.width > 0 && rect.height > 0) ?? range.getBoundingClientRect()
range.detach()
const sideMenuRect = sideMenu.getBoundingClientRect()
return Math.abs(
(sideMenuRect.top + sideMenuRect.height / 2) -
(textRect.top + textRect.height / 2),
)
})
expect(delta).toBeLessThanOrEqual(2)
}
async function expectSideMenuCenteredOnFirstTextLine(page: Page, text: string): Promise<void> {
const block = await blockOuterForText(page, text)
await block.hover()
await expect(page.locator('.bn-side-menu')).toBeVisible({ timeout: 5_000 })
const metrics = await block.evaluate((blockElement) => {
const content = blockElement.querySelector('.bn-block-content')
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
const sideMenu = document.querySelector('.bn-side-menu')
if (!inlineContent || !sideMenu) return null
const range = document.createRange()
range.selectNodeContents(inlineContent)
const lineRects = Array.from(range.getClientRects())
.filter((rect) => rect.width > 0 && rect.height > 0)
const textRect = range.getBoundingClientRect()
range.detach()
if (lineRects.length < 2 || textRect.height <= lineRects[0].height) return null
const firstLineCenter = lineRects[0].top + lineRects[0].height / 2
const fullTextCenter = textRect.top + textRect.height / 2
const sideMenuRect = sideMenu.getBoundingClientRect()
const sideMenuCenter = sideMenuRect.top + sideMenuRect.height / 2
return {
firstLineDelta: Math.abs(sideMenuCenter - firstLineCenter),
fullTextDelta: Math.abs(sideMenuCenter - fullTextCenter),
}
})
expect(metrics).not.toBeNull()
expect(metrics!.firstLineDelta).toBeLessThanOrEqual(2)
expect(metrics!.fullTextDelta).toBeGreaterThan(8)
}
async function dragHandleToBlock(page: Page, handle: Locator, targetBlock: Locator): Promise<void> {
const handleBox = await handle.boundingBox()
const targetBox = await targetBlock.boundingBox()
expect(handleBox).not.toBeNull()
expect(targetBox).not.toBeNull()
const start = {
x: handleBox!.x + handleBox!.width / 2,
y: handleBox!.y + handleBox!.height / 2,
}
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + 4, start.y + 4, { steps: 4 })
await page.mouse.move(start.x + 16, start.y + 16, { steps: 8 })
await page.mouse.move(
targetBox!.x + targetBox!.width / 2,
targetBox!.y + 2,
{ steps: 24 },
)
const dragPreview = page.getByTestId('editor-block-drag-preview')
const dropIndicator = page.getByTestId('editor-block-drop-indicator')
await expect(dragPreview).toBeVisible()
await expect(dragPreview).toHaveCSS('opacity', '0.72')
await expect(dropIndicator).toBeVisible()
await page.mouse.up()
await expect(dragPreview).toHaveCount(0)
await expect(dropIndicator).toHaveCount(0)
}
async function collapsedHeadingEllipsisPoint(heading: Locator): Promise<{ x: number; y: number }> {
const point = await heading.evaluate((headingElement) => {
const inlineHeading = headingElement.querySelector('.bn-inline-content')
if (!(inlineHeading instanceof HTMLElement)) return null
const range = document.createRange()
range.selectNodeContents(inlineHeading)
const textRect = Array.from(range.getClientRects())
.filter((rect) => rect.width > 0 && rect.height > 0)
.at(-1)
range.detach()
if (!textRect) return null
const parseLength = (value: string) => {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : 0
}
const afterStyle = window.getComputedStyle(inlineHeading, '::after')
const marginStart = parseLength(afterStyle.getPropertyValue('margin-inline-start'))
const dotsWidth = Math.max(
parseLength(afterStyle.width),
parseLength(afterStyle.minWidth),
) + parseLength(afterStyle.paddingLeft) + parseLength(afterStyle.paddingRight)
const isRtl = window.getComputedStyle(inlineHeading).direction === 'rtl'
return {
x: isRtl
? textRect.left - marginStart - dotsWidth / 2
: textRect.right + marginStart + dotsWidth / 2,
y: textRect.top + textRect.height / 2,
}
})
expect(point).not.toBeNull()
return point!
}
async function clickCollapsedHeadingEllipsis(page: Page, heading: Locator): Promise<void> {
const point = await collapsedHeadingEllipsisPoint(heading)
await page.mouse.click(point.x, point.y)
}
function writeAlphaProjectContent(content: string): void {
fs.writeFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), content, 'utf8')
}
test('dragging the left block handle reorders editor blocks', async ({ page }) => {
await page.getByText('Alpha Project', { exact: true }).first().click()
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5_000 })
const paragraph = await blockOuterForText(page, 'This is a test project that references other notes.')
const notesHeading = await blockOuterForText(page, 'Notes')
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/)
await expectSideMenuCenteredOnText(page, 'Alpha Project')
await expectSideMenuCenteredOnText(page, 'Notes')
const handle = await visibleLeftBlockHandle(page, notesHeading)
await dragHandleToBlock(page, handle, paragraph)
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*Notes[\s\S]*This is a test project/)
})
test('left block handle aligns with the first line of wrapped text', async ({ page }) => {
await page.setViewportSize({ width: 760, height: 720 })
await page.getByText('Alpha Project', { exact: true }).first().click()
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5_000 })
await page.addStyleTag({
content: '.bn-editor { max-width: 320px !important; }',
})
await expectSideMenuCenteredOnFirstTextLine(
page,
'This is a test project that references other notes.',
)
})
test('heading caret collapses the following section blocks and ellipsis expands them', async ({ page }) => {
await page.getByText('Alpha Project', { exact: true }).first().click()
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5_000 })
const notesHeading = await blockOuterForText(page, 'Notes')
const detailsBlock = editor.locator('.bn-block-outer').filter({ hasText: 'See' }).first()
await expect(detailsBlock).toBeVisible()
await notesHeading.hover()
const collapseButton = page.locator('.bn-side-menu button:has([data-test="headingCollapseToggle"])').first()
await expect(collapseButton).toBeVisible({ timeout: 5_000 })
await collapseButton.click()
await expect(detailsBlock).toBeHidden()
const dotsContent = await notesHeading.evaluate((heading) => {
const inlineHeading = heading.querySelector('.bn-inline-content')
if (!inlineHeading) return ''
return window.getComputedStyle(inlineHeading, '::after').content
})
expect(dotsContent).toBe('"..."')
const baseDotsBackground = await notesHeading.evaluate((heading) => {
const inlineHeading = heading.querySelector('.bn-inline-content')
if (!inlineHeading) return ''
return window.getComputedStyle(inlineHeading, '::after').backgroundColor
})
const ellipsisPoint = await collapsedHeadingEllipsisPoint(notesHeading)
await page.mouse.move(ellipsisPoint.x, ellipsisPoint.y)
await expect.poll(async () => notesHeading.evaluate((heading) => {
const inlineHeading = heading.querySelector('.bn-inline-content')
if (!inlineHeading) return ''
return window.getComputedStyle(inlineHeading).cursor
})).toBe('pointer')
const hoverDotsBackground = await notesHeading.evaluate((heading) => {
const inlineHeading = heading.querySelector('.bn-inline-content')
if (!inlineHeading) return ''
return window.getComputedStyle(inlineHeading, '::after').backgroundColor
})
expect(hoverDotsBackground).not.toBe(baseDotsBackground)
await clickCollapsedHeadingEllipsis(page, notesHeading)
await expect(detailsBlock).toBeVisible()
})
test('list item caret collapses child list items and ellipsis expands them', async ({ page }) => {
writeAlphaProjectContent(`---
Is A: Project
Status: Active
Owner: "Test User"
---
# Alpha Project
## Nested List
- Parent item
- Child item
- Grandchild item
- Second child item
- Sibling item
`)
await page.getByText('Alpha Project', { exact: true }).first().click()
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5_000 })
const parentItem = await blockOuterForText(page, 'Parent item')
const childItem = await blockOuterForText(page, 'Child item')
const grandchildItem = await blockOuterForText(page, 'Grandchild item')
const secondChildItem = await blockOuterForText(page, 'Second child item')
const siblingItem = await blockOuterForText(page, 'Sibling item')
await editor.getByText('Parent item', { exact: true }).hover()
const collapseButton = page.locator('.bn-side-menu button:has([data-test="headingCollapseToggle"])').first()
await expect(collapseButton).toBeVisible({ timeout: 5_000 })
await collapseButton.click()
await expect(childItem).toBeHidden()
await expect(grandchildItem).toBeHidden()
await expect(secondChildItem).toBeHidden()
await expect(siblingItem).toBeVisible()
const dotsContent = await parentItem.evaluate((item) => {
const inlineContent = item.querySelector('.bn-inline-content')
if (!inlineContent) return ''
return window.getComputedStyle(inlineContent, '::after').content
})
expect(dotsContent).toBe('"..."')
await clickCollapsedHeadingEllipsis(page, parentItem)
await expect(childItem).toBeVisible()
await expect(grandchildItem).toBeVisible()
await expect(secondChildItem).toBeVisible()
await expect(siblingItem).toBeVisible()
})