Files
dodo/frontend/e2e/ui-reduction-acceptance.spec.ts
T
bboysoul 1571c3946f
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m31s
feat: refine task and countdown interactions
2026-09-17 09:32:22 +08:00

233 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } })
}
function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function openSidebarView(page: Page, name: string) {
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
await page.locator('.sidebar').getByRole('button', { name, exact: true }).click()
}
async function expectNoHorizontalOverflow(page: Page, label: string, testInfo: { project: { name: string } }) {
const metrics = await page.evaluate(() => ({
viewport: { width: innerWidth, height: innerHeight },
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth },
body: { clientWidth: document.body.clientWidth, scrollWidth: document.body.scrollWidth },
}))
console.log(`[qa-metrics][${testInfo.project.name}][${label}] ${JSON.stringify(metrics)}`)
expect(metrics.document.scrollWidth).toBe(metrics.document.clientWidth)
expect(metrics.body.scrollWidth).toBeLessThanOrEqual(metrics.body.clientWidth)
}
async function taskRow(page: Page, title: string) {
const row = page.locator('.task-row').filter({ has: page.locator('.task-main strong', { hasText: title }) })
await expect(row).toHaveCount(1)
return row
}
async function createTask(request: APIRequestContext, baseURL: string, title: string, listId: string, dueAt?: string) {
const response = await mutate(request, baseURL, '/api/v1/tasks', {
method: 'POST',
data: { title, list_id: listId, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) },
})
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<{ id: string }>
}
async function createCountdown(request: APIRequestContext, baseURL: string, title: string, eventDate: string, pinned = false) {
const response = await mutate(request, baseURL, '/api/v1/countdowns', {
method: 'POST',
data: { title, event_date: eventDate, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false, pinned },
})
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<{ id: string }>
}
async function box(locator: Locator) {
const value = await locator.boundingBox()
expect(value).not.toBeNull()
return value!
}
test('task rows use the body for detail and Trash keeps distinct actions', async ({ page, request, baseURL }, testInfo) => {
const suffix = testInfo.project.name
const bootstrap = await request.get('/api/v1/bootstrap')
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const todayTitle = `验收今天超长任务标题-${suffix}-用于确认正文获得更多实际可用宽度`
const inboxTitle = `验收清单任务-${suffix}`
const trashTitle = `验收回收站任务-${suffix}`
await createTask(request, baseURL!, inboxTitle, inbox.id)
const trash = await createTask(request, baseURL!, trashTitle, inbox.id)
expect((await mutate(request, baseURL!, `/api/v1/tasks/${trash.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
await page.goto('/')
await page.getByRole('button', { name: '添加任务' }).click()
await page.getByLabel('任务名称').fill(todayTitle)
await page.getByRole('button', { name: '添加任务', exact: true }).click()
const todayRow = await taskRow(page, todayTitle)
expect(await todayRow.locator('.task-detail-trigger').count()).toBe(0)
const todayGeometry = await todayRow.evaluate(element => {
const row = element.getBoundingClientRect()
const main = element.querySelector<HTMLElement>('.task-main')!.getBoundingClientRect()
return { row: { x: row.x, width: row.width, right: row.right }, main: { x: main.x, width: main.width, right: main.right }, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth }
})
console.log(`[qa-metrics][${suffix}][today-task] ${JSON.stringify(todayGeometry)}`)
expect(todayGeometry.main.width).toBeGreaterThan(200)
expect(todayGeometry.scrollWidth).toBeLessThanOrEqual(todayGeometry.clientWidth)
await todayRow.locator('.task-main').click()
await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible()
await page.getByRole('button', { name: '关闭详情' }).click()
await openSidebarView(page, '收集箱')
const inboxRow = await taskRow(page, inboxTitle)
expect(await inboxRow.locator('.task-detail-trigger').count()).toBe(0)
await inboxRow.locator('.task-main').click()
await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible()
await page.getByRole('button', { name: '关闭详情' }).click()
await openSidebarView(page, '回收站')
const deletedRow = await taskRow(page, trashTitle)
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible()
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible()
expect(await deletedRow.locator('.task-detail-trigger').count()).toBe(0)
await expectNoHorizontalOverflow(page, 'tasks-trash', testInfo)
})
test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => {
await page.goto('/')
await bottomTab(page, '设置').click()
expect(await page.locator('.view-intro').count()).toBe(0)
await expect(page.locator('.settings-group')).toHaveCount(4)
expect(await page.locator('.settings-danger').count()).toBe(0)
const input = page.locator('input[type=file]')
await input.setInputFiles({ name: 'acceptance.zip', mimeType: 'application/zip', buffer: Buffer.from('zip-placeholder') })
await page.getByLabel('恢复方式').selectOption('replace')
const warning = page.locator('.restore-replace-warning')
await expect(warning).toBeVisible()
const restoreRow = page.getByText('恢复方式', { exact: true }).locator('..').locator('..')
const zipGeometry = { row: await box(restoreRow), warning: await box(warning) }
console.log(`[qa-metrics][${testInfo.project.name}][settings-zip] ${JSON.stringify(zipGeometry)}`)
expect(Math.abs(zipGeometry.warning.y - zipGeometry.row.y)).toBeLessThan(zipGeometry.row.height)
await input.setInputFiles({ name: 'legacy.csv', mimeType: 'text/csv', buffer: Buffer.from('\ufefftitle\nlegacy') })
await expect(page.getByLabel('恢复方式')).toHaveValue('merge')
await expect(page.getByLabel('恢复方式')).toBeDisabled()
await expect(page.locator('.restore-replace-warning')).toHaveCount(0)
await expect(page.locator('.backup-preflight.legacy')).not.toContainText(/替换现有数据|替换并恢复/)
await expectNoHorizontalOverflow(page, 'settings', testInfo)
})
test('Habits and Countdowns use reduced headers, compact rows, and continuous archive styling', async ({ page, request, baseURL }, testInfo) => {
const suffix = testInfo.project.name
const day = new Date().toLocaleDateString('sv-SE')
const focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true)
await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day)
const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day)
expect((await mutate(request, baseURL!, `/api/v1/countdowns/${archived.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
expect(focus.id).toBeTruthy()
await page.goto('/')
await bottomTab(page, '习惯').click()
expect(await page.locator('.view-intro').count()).toBe(0)
await expectNoHorizontalOverflow(page, 'habits', testInfo)
await bottomTab(page, '倒数日').click()
await expect(page.locator('.countdown-summary')).toContainText(/\d+ 个重要日子/)
expect(await page.locator('.countdown-hero').count()).toBe(0)
const activeRow = page.locator('.countdown-row').filter({ hasText: `普通倒数-${suffix}` })
await expect(activeRow).toHaveCount(1)
expect(await activeRow.locator('.countdown-main > small').count()).toBe(1)
expect(await activeRow.locator('.countdown-badges, .pinned-icon').count()).toBe(0)
const rowText = await activeRow.innerText()
expect(rowText).not.toMatch(/农历|不重复|每周|每月|每年/)
const toggle = page.getByRole('button', { name: /已归档(1/ })
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await expect(toggle).toHaveAttribute('aria-controls', 'archived-countdowns')
const toggleBox = await box(toggle)
expect(toggleBox.height).toBeGreaterThanOrEqual(44)
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
const archive = page.locator('#archived-countdowns')
const archiveMetrics = await archive.evaluate(element => {
const style = getComputedStyle(element)
const articles = [...element.querySelectorAll<HTMLElement>('article')].map(article => {
const itemStyle = getComputedStyle(article)
return { borderRadius: itemStyle.borderRadius, boxShadow: itemStyle.boxShadow, borderTop: itemStyle.borderTopWidth, borderBottom: itemStyle.borderBottomWidth }
})
const buttons = [...element.querySelectorAll<HTMLElement>('button')].map(button => ({ text: button.innerText, ...button.getBoundingClientRect().toJSON() }))
return { container: { borderRadius: style.borderRadius, boxShadow: style.boxShadow }, articles, buttons }
})
console.log(`[qa-metrics][${testInfo.project.name}][countdown-archive] ${JSON.stringify(archiveMetrics)}`)
expect(archiveMetrics.container.boxShadow).toBe('none')
expect(archiveMetrics.articles.every(item => item.borderRadius === '0px' && item.boxShadow === 'none')).toBeTruthy()
expect(archiveMetrics.buttons.every(item => item.width >= 44 && item.height >= 44)).toBeTruthy()
await expectNoHorizontalOverflow(page, 'countdowns', testInfo)
})
test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and desktop-wide', async ({ page }, testInfo) => {
await page.goto('/')
await openSidebarView(page, '备忘录')
const toolbar = page.locator('.memo-toolbar')
const scope = page.locator('.memo-scope')
const toggle = page.getByRole('button', { name: '展开搜索备忘录' })
const collapsedGeometry = { toolbar: await box(toolbar), scope: await box(scope), toggle: await box(toggle) }
console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-collapsed] ${JSON.stringify(collapsedGeometry)}`)
expect(collapsedGeometry.toggle.width).toBeGreaterThanOrEqual(44)
expect(collapsedGeometry.toggle.height).toBeGreaterThanOrEqual(44)
expect(Math.abs(collapsedGeometry.scope.y - collapsedGeometry.toggle.y)).toBeLessThanOrEqual(4)
expect(collapsedGeometry.toolbar.height).toBeLessThanOrEqual(52)
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await toggle.click()
const closeToggle = page.getByRole('button', { name: '收起搜索备忘录' })
const input = page.getByRole('textbox', { name: '搜索备忘录' })
await expect(closeToggle).toHaveAttribute('aria-expanded', 'true')
await expect(input).toBeFocused()
await input.fill(`保留查询-${testInfo.project.name}`)
const openPanel = page.locator('#memo-search-panel')
const openMetrics = await openPanel.evaluate(element => {
const rect = element.getBoundingClientRect()
return { x: rect.x, right: rect.right, width: rect.width, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth }
})
console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-open] ${JSON.stringify(openMetrics)}`)
expect(openMetrics.x).toBeGreaterThanOrEqual(0)
expect(openMetrics.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1)
expect(openMetrics.scrollWidth).toBeLessThanOrEqual(openMetrics.clientWidth)
await closeToggle.click()
await expect(openPanel).toBeHidden()
await expect(toggle).toBeFocused()
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await toggle.click()
await expect(input).toHaveValue(`保留查询-${testInfo.project.name}`)
await expect(input).toBeFocused()
await expectNoHorizontalOverflow(page, 'memo-mobile', testInfo)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(openPanel).toBeVisible()
const desktopMetrics = await openPanel.evaluate(element => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return { viewport: { width: innerWidth, height: innerHeight }, x: rect.x, y: rect.y, width: rect.width, display: style.display, hidden: (element as HTMLElement).hidden, documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth }
})
console.log(`[qa-metrics][${testInfo.project.name}][memo-desktop] ${JSON.stringify(desktopMetrics)}`)
expect(desktopMetrics.hidden).toBeFalsy()
expect(desktopMetrics.width).toBeGreaterThanOrEqual(320)
expect(desktopMetrics.documentOverflow).toBe(0)
await expect(toggle).toBeHidden()
})