258 lines
15 KiB
TypeScript
258 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)
|
|
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)
|
|
})
|