Files
dodo/frontend/e2e/ui-reduction-acceptance.spec.ts
T
bboysoul f3ad1eec03
ci / gitleaks (push) Successful in 56s
ci / docker (push) Successful in 5m55s
feat: add iCal calendar subscriptions
2026-09-20 16:37:43 +08:00

203 lines
12 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 { allowExpectedError, 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}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
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 restoreTitle = `验收恢复任务-${suffix}`
const purgeTitle = `验收永久删除任务-${suffix}`
await createTask(request, baseURL!, inboxTitle, inbox.id)
const restoreCandidate = await createTask(request, baseURL!, restoreTitle, inbox.id)
const purgeCandidate = await createTask(request, baseURL!, purgeTitle, inbox.id)
expect((await mutate(request, baseURL!, `/api/v1/tasks/${restoreCandidate.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
expect((await mutate(request, baseURL!, `/api/v1/tasks/${purgeCandidate.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 restoreRow = await taskRow(page, restoreTitle)
const purgeRow = await taskRow(page, purgeTitle)
for (const deletedRow of [restoreRow, purgeRow]) {
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible()
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible()
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role')
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex')
expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0)
}
await restoreRow.getByRole('button', { name: '恢复' }).click()
await expect(restoreRow).toHaveCount(0)
await purgeRow.getByRole('button', { name: '永久删除' }).click()
const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` })
await expect(purgeDialog).toBeVisible()
// The UI can abort the completed 204 request while the confirmation overlay closes.
allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`)
await purgeDialog.getByRole('button', { name: '确认', exact: true }).click()
await expect(purgeRow).toHaveCount(0)
await page.reload()
await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0)
await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(0)
await openSidebarView(page, '收集箱')
await expect(await taskRow(page, restoreTitle)).toHaveCount(1)
await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(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 openSidebarView(page, '设置')
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}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())
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)
})