Files
dodo/frontend/e2e/task-row-rhythm.spec.ts
bboysoul 462bf00b57
ci / gitleaks (push) Successful in 6s
ci / docker (push) Successful in 3m42s
style: clarify task row states
2026-09-19 14:39:00 +08:00

161 lines
7.6 KiB
TypeScript

import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { expect, test } from './fixtures'
async function csrf(request: APIRequestContext) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
}
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } })
}
async function expectTaskRowGeometry(row: Locator) {
const metrics = await row.evaluate((element: HTMLElement) => {
const controls = [...element.querySelectorAll<HTMLElement>('button')].map(control => {
const rect = control.getBoundingClientRect()
return { width: rect.width, height: rect.height }
})
const tail = element.querySelector<HTMLElement>('.task-tail')
const tailRect = tail?.getBoundingClientRect()
const rowRect = element.getBoundingClientRect()
const directChildrenInside = [...element.children].every(child => {
const rect = (child as HTMLElement).getBoundingClientRect()
return rect.left >= rowRect.left && rect.right <= rowRect.right && rect.top >= rowRect.top && rect.bottom <= rowRect.bottom
})
return {
height: rowRect.height,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
controls,
tailInside: !tailRect || (tailRect.left >= rowRect.left && tailRect.right <= rowRect.right),
directChildrenInside,
}
})
expect(metrics.height).toBeGreaterThanOrEqual(57)
expect(metrics.height).toBeLessThanOrEqual(59)
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
expect(metrics.tailInside).toBe(true)
expect(metrics.directChildrenInside).toBe(true)
for (const control of metrics.controls) {
expect(control.width).toBeGreaterThanOrEqual(44)
expect(control.height).toBeGreaterThanOrEqual(44)
}
}
async function openInbox(page: Page) {
if ((await page.viewportSize())!.width <= 930) {
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
}
await page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }).click()
}
test('task rows keep approved rhythm without clipping across desktop and mobile', async ({ page, request, baseURL }) => {
const suffix = `${test.info().project.name}-${Date.now()}`
const bootstrap = await request.get('/api/v1/bootstrap')
expect(bootstrap.ok()).toBeTruthy()
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
const create = async (title: string, extra: Record<string, unknown> = {}) => {
const response = await mutate(request, baseURL!, '/api/v1/tasks', {
method: 'POST', data: { title, list_id: inbox.id, ...extra },
})
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<{ id: string }>
}
const ordinaryTitle = `普通任务-${suffix}`
const completedTitle = `完成任务-${suffix}`
const longTitle = `超长任务-${suffix}-` + '这是一段用于确认标题省略且不撑高任务行的连续文字'.repeat(5)
const dueTitle = `含截止日期-${suffix}`
const parentTitle = `含子任务-${suffix}`
const localDate = (offset: number) => {
const shanghaiDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date())
const date = new Date(`${shanghaiDate}T12:00:00+08:00`)
date.setUTCDate(date.getUTCDate() + offset)
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(date)
}
await create(ordinaryTitle)
await create(completedTitle)
await create(longTitle)
await create(dueTitle, { due_at: `${localDate(1)}T23:59:00`, due_has_time: true })
const parent = await create(parentTitle)
await create(`子任务-${suffix}`, { parent_id: parent.id })
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/')
await openInbox(page)
const titles = [ordinaryTitle, completedTitle, longTitle, dueTitle, parentTitle]
for (const title of titles) {
const row = page.locator('.task-row').filter({ hasText: title })
await expect(row).toHaveCount(1)
await expectTaskRowGeometry(row)
}
const ordinaryRow = page.locator('.task-row').filter({ hasText: ordinaryTitle })
const completedRow = page.locator('.task-row').filter({ hasText: completedTitle })
const rowState = async (row: Locator) => row.evaluate((element: HTMLElement) => {
const style = getComputedStyle(element)
const marker = getComputedStyle(element, '::before')
const title = getComputedStyle(element.querySelector('.task-main strong') as HTMLElement)
return {
background: style.backgroundColor,
markerWidth: marker.width,
markerBackground: marker.backgroundColor,
titleColor: title.color,
titleDecoration: title.textDecorationLine,
}
})
expect((await rowState(ordinaryRow)).background).toBe('rgba(0, 0, 0, 0)')
const hasFinePointer = await page.evaluate(() => matchMedia('(hover:hover) and (pointer:fine)').matches)
if (hasFinePointer) {
await ordinaryRow.hover()
await expect(ordinaryRow).toHaveCSS('background-color', 'rgb(255, 248, 238)')
}
await ordinaryRow.locator('.task-main').click()
await expect(ordinaryRow).toHaveCSS('background-color', 'rgb(255, 244, 229)')
const selectedState = await rowState(ordinaryRow)
expect(selectedState.markerWidth).toBe('2px')
expect(selectedState.markerBackground).toBe('rgb(241, 90, 41)')
const ordinaryBox = await ordinaryRow.boundingBox()
expect(ordinaryBox).toBeTruthy()
await page.mouse.move(ordinaryBox!.x + ordinaryBox!.width / 2, ordinaryBox!.y + ordinaryBox!.height / 2)
await page.mouse.down()
expect((await rowState(ordinaryRow)).background).toBe('rgb(255, 244, 229)')
await page.mouse.up()
await ordinaryRow.evaluate(element => element.classList.add('done', 'overdue-task'))
expect((await rowState(ordinaryRow)).background).toBe('rgb(255, 244, 229)')
await ordinaryRow.evaluate(element => {
element.classList.remove('selected', 'done', 'overdue-task')
element.classList.add('task-row--trash')
})
await ordinaryRow.hover()
const trashState = await rowState(ordinaryRow)
expect(trashState.background).toBe('rgba(0, 0, 0, 0)')
expect(trashState.markerBackground).toBe('rgba(0, 0, 0, 0)')
await completedRow.getByRole('button', { name: `完成${completedTitle}` }).click()
await expect(completedRow).toHaveClass(/done/)
await expect(completedRow).toHaveCSS('background-color', 'rgb(251, 247, 240)')
await expect(completedRow.locator('.task-main strong')).toHaveCSS('color', 'rgb(112, 104, 95)')
const completedState = await rowState(completedRow)
expect(completedState.titleDecoration).toContain('line-through')
await expectTaskRowGeometry(completedRow)
await expect(page.locator('.task-row').filter({ hasText: dueTitle }).locator('.task-tail')).toBeVisible()
await expect(page.locator('.task-row').filter({ hasText: parentTitle })).toContainText('0/1')
for (const viewport of [{ width: 390, height: 844 }, { width: 375, height: 667 }]) {
await page.setViewportSize(viewport)
for (const title of titles) {
await expectTaskRowGeometry(page.locator('.task-row').filter({ hasText: title }))
}
const pageMetrics = await page.evaluate(() => ({ clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth }))
expect(pageMetrics.scrollWidth).toBeLessThanOrEqual(pageMetrics.clientWidth)
}
})