Files
dodo/frontend/e2e/visual-polish.spec.ts
T
bboysoul a877d341f7
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m38s
fix: align numeric habit progress
2026-09-20 06:58:03 +08:00

268 lines
15 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 createHabit(request: APIRequestContext, baseURL: string, name: string, extra: Record<string, unknown> = {}) {
const response = await mutate(request, baseURL, '/api/v1/habits', {
method: 'POST',
data: { name, kind: 'boolean', schedule_type: 'daily', ...extra },
})
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<{ id: string }>
}
function shanghaiToday() {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
}).formatToParts(new Date())
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)!.value
const weekdays: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 }
return { day: `${value('year')}-${value('month')}-${value('day')}`, weekday: weekdays[value('weekday')] }
}
function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function openSidebarView(page: Page, name: string) {
if ((page.viewportSize()?.width ?? 0) <= 930) {
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
}
const target = page.locator('.sidebar').getByRole('button', { name, exact: true })
await expect(target).toBeInViewport()
await target.click()
}
async function expectHeightInRange(locator: Locator, minimum: number, maximum: number) {
const box = await locator.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeGreaterThanOrEqual(minimum)
expect(box!.height).toBeLessThanOrEqual(maximum)
}
async function expectNotClipped(locator: Locator) {
const metrics = await locator.evaluate((element: HTMLElement) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight)
}
test('boolean habits complete in place while paused and unscheduled rows explain why they are read-only', async ({ page, request, baseURL }, testInfo) => {
const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const today = shanghaiToday()
const booleanName = `布尔习惯-${suffix}`
const pausedName = `暂停习惯-${suffix}`
const unscheduledName = `未安排习惯-${suffix}`
await createHabit(request, baseURL!, booleanName)
const paused = await createHabit(request, baseURL!, pausedName)
await createHabit(request, baseURL!, unscheduledName, { schedule_type: 'weekly', weekdays: [(today.weekday + 1) % 7] })
const pause = await mutate(request, baseURL!, `/api/v1/habits/${paused.id}/pauses`, {
method: 'POST', data: { start_date: today.day, end_date: today.day },
})
expect(pause.ok(), await pause.text()).toBeTruthy()
await page.goto('/')
if ((page.viewportSize()?.width ?? 0) <= 930) await bottomTab(page, '习惯').click()
else await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click()
const booleanRow = page.locator('.habit-row').filter({ hasText: booleanName })
await expect(booleanRow.getByRole('progressbar')).toHaveCount(0)
const booleanCheck = booleanRow.getByRole('button', { name: `完成${booleanName}一次` })
const checkGeometry = await booleanCheck.evaluate((element) => {
const control = element.getBoundingClientRect()
const row = element.closest('.habit-row')!.getBoundingClientRect()
return {
visibleWidth: control.width - Math.max(row.left - control.left, 0) - Math.max(control.right - row.right, 0),
height: control.height,
}
})
expect(checkGeometry.visibleWidth).toBeGreaterThanOrEqual(44)
expect(checkGeometry.height).toBeGreaterThanOrEqual(44)
await booleanCheck.click()
await expect(booleanRow).toHaveClass(/done/)
await expect(booleanRow).toHaveCSS('background-color', 'rgb(251, 247, 240)')
await expect(booleanRow.getByRole('button', { name: `减少${booleanName}一次` })).toHaveAttribute('aria-pressed', 'true')
await booleanRow.locator('.habit-main').click()
await expect(booleanRow).toHaveClass(/selected/)
await expect(booleanRow).toHaveCSS('background-color', 'rgb(255, 244, 229)')
await expect(page.getByRole('dialog', { name: booleanName })).toBeVisible()
await page.getByRole('button', { name: '关闭习惯详情' }).click()
await expect(booleanRow).not.toHaveClass(/selected/)
await page.reload()
const persistedBooleanRow = page.locator('.habit-row').filter({ hasText: booleanName })
await expect(persistedBooleanRow).toHaveClass(/done/)
await persistedBooleanRow.locator('.habit-main').focus()
await persistedBooleanRow.locator('.habit-main').press('Enter')
await expect(page.getByRole('dialog', { name: booleanName })).toBeVisible()
await page.getByRole('button', { name: '关闭习惯详情' }).click()
const pausedRow = page.locator('.habit-row').filter({ hasText: pausedName })
await expect(pausedRow).toContainText('今天已暂停')
await expect(pausedRow.getByRole('button', { name: '今天已暂停' })).toBeDisabled()
const unscheduledRow = page.locator('.habit-row').filter({ hasText: unscheduledName })
await expect(unscheduledRow).toContainText('今天未安排')
await expect(unscheduledRow.getByRole('button', { name: '今天未安排' })).toBeDisabled()
})
test('approved polish keeps dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => {
const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const bootstrapResponse = await request.get('/api/v1/bootstrap')
expect(bootstrapResponse.ok()).toBeTruthy()
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const taskTitle = `长标题任务-${suffix}-这是用于检查长标题和右侧控件不会裁切的任务标题`
const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', {
method: 'POST',
data: { title: taskTitle, list_id: inbox.id },
})
expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy()
const memoTitle = `Markdown 摘要-${suffix}`
const memoResponse = await mutate(request, baseURL!, '/api/v1/memos', {
method: 'POST',
data: { title: memoTitle, content: '# 标题\n\n**重点** 与 [链接文字](https://example.com)\n\n- 列表项' },
})
expect(memoResponse.ok(), await memoResponse.text()).toBeTruthy()
const day = shanghaiToday().day
const countdownTitle = `紧凑倒数-${suffix}`
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', {
method: 'POST',
data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false, pinned: true },
})
expect(countdownResponse.ok(), await countdownResponse.text()).toBeTruthy()
await page.goto('/')
await openSidebarView(page, '收集箱')
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
const taskRow = page.locator('.task-row').filter({ hasText: taskTitle })
await expect(taskRow).toHaveCount(1)
await expectHeightInRange(taskRow, 57, 59)
await expectNotClipped(taskRow)
const transparent = 'rgba(0, 0, 0, 0)'
await expect(taskRow).toHaveCSS('background-color', transparent)
await taskRow.hover()
await expect(taskRow).toHaveCSS('background-color', testInfo.project.name === 'desktop-1440' ? 'rgb(255, 248, 238)' : transparent)
await taskRow.locator('.task-main').click()
await expect(taskRow).toHaveClass(/selected/)
await expect(taskRow).toHaveCSS('background-color', 'rgb(255, 244, 229)')
const taskDetail = page.getByRole('dialog', { name: '任务详情' })
await expect(taskDetail.getByRole('button', { name: '保存更改' })).toBeEnabled()
await taskDetail.getByRole('button', { name: '关闭详情' }).click()
await expect(taskDetail).toBeHidden()
if ((page.viewportSize()?.width ?? 0) <= 930) await bottomTab(page, '习惯').click()
else await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click()
const mobileHabitGeometry = await page.evaluate(() => {
const heading = document.querySelector<HTMLElement>('.habit-section-heading')!.getBoundingClientRect()
const list = document.querySelector<HTMLElement>('.habit-list')!.getBoundingClientRect()
return { headingWidth: heading.width, listWidth: list.width, headingLeft: heading.left, listLeft: list.left }
})
expect(mobileHabitGeometry.headingWidth).toBeCloseTo(mobileHabitGeometry.listWidth, 0)
expect(mobileHabitGeometry.headingLeft).toBeCloseTo(mobileHabitGeometry.listLeft, 0)
const habitName = `长标题习惯-${suffix}-检查进度与按钮不被裁切`
await page.getByRole('button', { name: '添加习惯' }).click()
await page.getByLabel('新习惯名称').fill(habitName)
await page.getByLabel('习惯类型').selectOption('numeric')
await page.getByLabel('目标值').fill('8')
await page.getByRole('button', { name: '添加习惯', exact: true }).click()
const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
await expect(habitRow).toHaveCount(1)
await expect(habitRow.getByRole('progressbar', { name: `${habitName}进度:0 / 8` })).toHaveAttribute('max', '8')
await expect(habitRow.getByRole('progressbar', { name: `${habitName}进度:0 / 8` })).toHaveAttribute('value', '0')
await expect(habitRow.locator('.habit-row-meta')).toHaveText('0 / 8')
await expectHeightInRange(habitRow, 57, 59)
await expectNotClipped(habitRow)
const habitVerticalLayout = await habitRow.evaluate((row) => {
const rowBox = row.getBoundingClientRect()
const titleBox = row.querySelector<HTMLElement>('.habit-name')!.getBoundingClientRect()
const progressBox = row.querySelector<HTMLElement>('.habit-progress-bar')!.getBoundingClientRect()
const groupCenter = (titleBox.top + progressBox.bottom) / 2
return { groupOffset: groupCenter - (rowBox.top + rowBox.height / 2), progressBottomGap: rowBox.bottom - progressBox.bottom }
})
expect(habitVerticalLayout.groupOffset).toBeGreaterThanOrEqual(-2)
expect(habitVerticalLayout.groupOffset).toBeLessThanOrEqual(2)
expect(habitVerticalLayout.progressBottomGap).toBeGreaterThanOrEqual(12)
await habitRow.getByRole('button', { name: `完成${habitName}一次` }).click()
await expect(habitRow.getByRole('progressbar', { name: `${habitName}进度:1 / 8` })).toHaveAttribute('value', '1')
await expect(habitRow.locator('.habit-row-meta')).toHaveText('1 / 8')
if ((page.viewportSize()?.width ?? 0) <= 930) await bottomTab(page, '今天').click()
else await page.locator('.sidebar').getByRole('button', { name: '今天', exact: true }).click()
const todayHabitRow = page.locator('.today-habit-row').filter({ hasText: habitName })
await expect(todayHabitRow.getByRole('progressbar', { name: `${habitName}进度:1 / 8` })).toHaveAttribute('max', '8')
await expect(todayHabitRow.getByRole('progressbar', { name: `${habitName}进度:1 / 8` })).toHaveAttribute('value', '1')
await expect(todayHabitRow.locator('.habit-row-meta')).toHaveText('1 / 8')
await expectHeightInRange(todayHabitRow, 57, 59)
await expectNotClipped(todayHabitRow)
await todayHabitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
const habitDetail = page.getByRole('dialog', { name: habitName })
await expect(habitDetail.getByRole('heading', { name: habitName, exact: true })).toHaveCount(1)
await expect(habitDetail).not.toContainText('习惯详情')
await habitDetail.getByRole('button', { name: '关闭习惯详情' }).click()
await page.setViewportSize({ width: 1440, height: 900 })
await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click()
const desktopHabitGeometry = await page.evaluate(() => {
const view = document.querySelector<HTMLElement>('main.list-main > .mvp-view')!.getBoundingClientRect()
const heading = document.querySelector<HTMLElement>('.habit-section-heading')!.getBoundingClientRect()
const list = document.querySelector<HTMLElement>('.habit-list')!.getBoundingClientRect()
return { viewWidth: view.width, headingWidth: heading.width, listWidth: list.width, headingLeft: heading.left, listLeft: list.left }
})
expect(desktopHabitGeometry.viewWidth).toBeGreaterThan(630)
expect(desktopHabitGeometry.headingWidth).toBeCloseTo(desktopHabitGeometry.listWidth, 0)
expect(desktopHabitGeometry.headingLeft).toBeCloseTo(desktopHabitGeometry.listLeft, 0)
await page.setViewportSize({ width: testInfo.project.name === 'mobile-390' ? 390 : 375, height: testInfo.project.name === 'mobile-390' ? 844 : 667 })
await bottomTab(page, '倒数日').click()
const focus = page.locator('.countdown-focus').filter({ hasText: countdownTitle })
await expect(focus).toHaveCount(1)
await expectHeightInRange(focus, 136, 148)
await expect(focus).not.toContainText(/置顶的重要日子|下一个重要日子|还有|已经过去/)
await expectNotClipped(focus)
await focus.click()
const countdownDetail = page.getByRole('dialog', { name: countdownTitle })
await expect(countdownDetail.getByRole('heading', { name: countdownTitle, exact: true })).toHaveCount(1)
await expect(countdownDetail).not.toContainText('重要日子详情')
await countdownDetail.getByRole('button', { name: '关闭详情' }).click()
await openSidebarView(page, '备忘录')
const memoRow = page.locator('.memo-row').filter({ hasText: memoTitle })
await expect(memoRow).toHaveCount(1)
await expectHeightInRange(memoRow, 72, 76)
await expect(memoRow.locator('.memo-row__excerpt')).toHaveCount(0)
await expect(memoRow).not.toContainText('标题 重点 与 链接文字 列表项')
await expect(memoRow.getByText(memoTitle, { exact: true })).toHaveCount(1)
await expect(memoRow.locator('time')).toHaveCount(1)
await expectNotClipped(memoRow)
const shortMemoTitle = `短正文-${suffix}`
const shortMemoResponse = await mutate(request, baseURL!, '/api/v1/memos', {
method: 'POST',
data: { title: shortMemoTitle, content: 'BODY_ONLY_SHORT' },
})
expect(shortMemoResponse.ok(), await shortMemoResponse.text()).toBeTruthy()
await page.reload()
const shortMemoRow = page.locator('.memo-row').filter({ hasText: shortMemoTitle })
await expect(shortMemoRow).toHaveCount(1)
await expectHeightInRange(shortMemoRow, 72, 76)
await expect(shortMemoRow.locator('.memo-row__excerpt')).toHaveCount(0)
await expect(shortMemoRow).not.toContainText('BODY_ONLY_SHORT')
await page.setViewportSize({ width: 1440, height: 900 })
const desktopInbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
await expect(desktopInbox).toBeInViewport()
await desktopInbox.click()
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
})