180 lines
10 KiB
TypeScript
180 lines
10 KiB
TypeScript
import type { APIRequestContext, 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 shanghaiDay(offset = 0) {
|
|
const instant = new Date(Date.now() + offset * 86_400_000)
|
|
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(instant)
|
|
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)!.value
|
|
return `${value('year')}-${value('month')}-${value('day')}`
|
|
}
|
|
|
|
async function createHabit(request: APIRequestContext, baseURL: string, name: string, kind: 'boolean' | 'numeric', target = 1, extra: Record<string, unknown> = {}) {
|
|
const response = await mutate(request, baseURL, '/api/v1/habits', {
|
|
method: 'POST', data: { name, kind, target, max_value: kind === 'numeric' ? target * 2 : 1, schedule_type: 'daily', ...extra },
|
|
})
|
|
expect(response.ok(), await response.text()).toBeTruthy()
|
|
return response.json() as Promise<{ id: string }>
|
|
}
|
|
|
|
async function openHabits(page: Page) {
|
|
await page.goto('/')
|
|
if ((page.viewportSize()?.width ?? 0) <= 930) {
|
|
await page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '习惯', exact: true }).click()
|
|
} else {
|
|
await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click()
|
|
}
|
|
}
|
|
|
|
async function expectNoHorizontalOverflow(page: Page) {
|
|
const widths = await page.evaluate(() => ({ client: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }))
|
|
expect(widths.scroll).toBeLessThanOrEqual(widths.client)
|
|
}
|
|
|
|
test('habit detail paper flow is responsive, ordered, scrollable, and preserves overlay contracts', async ({ page, request, baseURL }, testInfo) => {
|
|
const nonce = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
const numericName = `纸页数字-${nonce}`
|
|
const booleanName = `纸页布尔-${nonce}`
|
|
const archivedName = `纸页归档-${nonce}`
|
|
const numeric = await createHabit(request, baseURL!, numericName, 'numeric', 100)
|
|
const boolean = await createHabit(request, baseURL!, booleanName, 'boolean')
|
|
const archived = await createHabit(request, baseURL!, archivedName, 'numeric', 7, { start_date: shanghaiDay(-30) })
|
|
for (const [habit, value] of [[numeric, 68], [boolean, 1]] as const) {
|
|
const response = await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${shanghaiDay()}`, { method: 'PUT', data: { value } })
|
|
expect(response.ok(), await response.text()).toBeTruthy()
|
|
}
|
|
for (let offset = 0; offset > -24; offset -= 1) {
|
|
const response = await mutate(request, baseURL!, `/api/v1/habits/${archived.id}/logs/${shanghaiDay(offset)}`, { method: 'PUT', data: { value: Math.abs(offset % 8) } })
|
|
expect(response.ok(), await response.text()).toBeTruthy()
|
|
}
|
|
const archiveResponse = await mutate(request, baseURL!, `/api/v1/habits/${archived.id}`, { method: 'DELETE' })
|
|
expect(archiveResponse.ok(), await archiveResponse.text()).toBeTruthy()
|
|
|
|
await openHabits(page)
|
|
await expectNoHorizontalOverflow(page)
|
|
|
|
const shell = page.locator('.shell')
|
|
const main = page.locator('main')
|
|
const mainBeforeDetail = await main.boundingBox()
|
|
|
|
const numericOpener = page.locator('.habit-row').filter({ hasText: numericName }).getByRole('button', { name: `查看习惯详情:${numericName}` })
|
|
await numericOpener.click()
|
|
let dialog = page.getByRole('dialog', { name: numericName })
|
|
await expect(shell).toHaveClass(/detail-open/)
|
|
if ((page.viewportSize()?.width ?? 0) > 930) {
|
|
const [mainAfterDetail, detailBox] = await Promise.all([main.boundingBox(), dialog.boundingBox()])
|
|
expect(mainBeforeDetail).not.toBeNull()
|
|
expect(mainAfterDetail).not.toBeNull()
|
|
expect(detailBox).not.toBeNull()
|
|
expect(mainBeforeDetail!.width - mainAfterDetail!.width).toBeCloseTo(350, 0)
|
|
expect(detailBox!.width).toBeCloseTo(350, 0)
|
|
expect(detailBox!.x).toBeCloseTo(mainAfterDetail!.x + mainAfterDetail!.width, 0)
|
|
expect(detailBox!.height).toBeCloseTo(page.viewportSize()!.height, 0)
|
|
}
|
|
await expect(dialog.locator('.habit-detail-hero')).toContainText('68 / 100')
|
|
await expect(dialog.locator('.habit-detail-progress-row')).toContainText('68%')
|
|
await expect(dialog.locator('.habit-detail-archive-note')).toHaveCount(0)
|
|
if ((page.viewportSize()?.width ?? 0) <= 930) {
|
|
const originalViewport = page.viewportSize()!
|
|
await page.setViewportSize({ width: 720, height: 900 })
|
|
const archiveButton = dialog.getByRole('button', { name: '归档习惯' })
|
|
const archiveBox = await archiveButton.boundingBox()
|
|
expect(archiveBox).not.toBeNull()
|
|
expect(archiveBox!.width).toBeGreaterThanOrEqual(44)
|
|
expect(archiveBox!.height).toBeGreaterThanOrEqual(44)
|
|
await page.setViewportSize(originalViewport)
|
|
}
|
|
const activeOrder = await dialog.evaluate(element => {
|
|
const selectors = ['.habit-detail-header', '.habit-detail-hero', '.habit-detail-progress-row', '.habit-detail-meta', '.habit-history', '.habit-detail-active-actions']
|
|
const nodes = selectors.map(selector => element.querySelector(selector)!)
|
|
return nodes.slice(0, -1).every((node, index) => Boolean(node.compareDocumentPosition(nodes[index + 1]) & Node.DOCUMENT_POSITION_FOLLOWING))
|
|
})
|
|
expect(activeOrder).toBe(true)
|
|
await dialog.getByRole('button', { name: '关闭习惯详情' }).click()
|
|
await expect(numericOpener).toBeFocused()
|
|
|
|
const booleanOpener = page.locator('.habit-row').filter({ hasText: booleanName }).getByRole('button', { name: `查看习惯详情:${booleanName}` })
|
|
await booleanOpener.click()
|
|
dialog = page.getByRole('dialog', { name: booleanName })
|
|
await expect(dialog.locator('.habit-detail-hero')).toContainText('已完成')
|
|
await dialog.getByRole('button', { name: '关闭习惯详情' }).click()
|
|
|
|
const archiveToggle = page.locator('.habit-archive-toggle')
|
|
await archiveToggle.click()
|
|
const archivedOpener = page.getByRole('button', { name: new RegExp(archivedName) })
|
|
await archivedOpener.click()
|
|
dialog = page.getByRole('dialog', { name: archivedName })
|
|
await expect(dialog).toHaveAttribute('aria-labelledby', 'habit-detail-title')
|
|
await expect(dialog.locator('.habit-detail-archive-note')).toHaveText('此习惯已归档,历史记录仍完整保留。')
|
|
await expect(dialog.locator('.habit-detail-hero')).toHaveCount(0)
|
|
await expect(dialog.locator('.habit-detail-progress-row')).toHaveCount(0)
|
|
await expect(dialog).not.toContainText('今日进度')
|
|
const order = await dialog.evaluate(element => {
|
|
const note = element.querySelector('.habit-detail-archive-note')!
|
|
const header = element.querySelector('.habit-detail-header')!
|
|
return Boolean(note.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
})
|
|
expect(order).toBe(true)
|
|
|
|
const body = dialog.locator('.habit-detail-body')
|
|
const footer = dialog.locator('.habit-detail-archived-actions')
|
|
await expect(footer).toBeInViewport()
|
|
const before = await Promise.all([dialog.locator('.habit-detail-header').boundingBox(), footer.boundingBox()])
|
|
const scroll = await body.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop }))
|
|
expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight)
|
|
await body.evaluate(element => { element.scrollTop = element.scrollHeight })
|
|
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
|
|
const after = await Promise.all([dialog.locator('.habit-detail-header').boundingBox(), footer.boundingBox()])
|
|
expect(after[0]?.y).toBeCloseTo(before[0]!.y, 0)
|
|
expect(after[1]?.y).toBeCloseTo(before[1]!.y, 0)
|
|
await expectNoHorizontalOverflow(page)
|
|
|
|
if ((page.viewportSize()?.width ?? 0) <= 930) {
|
|
for (const button of [dialog.getByRole('button', { name: '关闭习惯详情' }), dialog.getByRole('button', { name: '永久删除' }), dialog.getByRole('button', { name: '恢复习惯' })]) {
|
|
const box = await button.boundingBox()
|
|
expect(box).not.toBeNull()
|
|
expect(box!.width).toBeGreaterThanOrEqual(44)
|
|
expect(box!.height).toBeGreaterThanOrEqual(44)
|
|
}
|
|
}
|
|
|
|
let releaseRestore!: () => void
|
|
const restoreGate = new Promise<void>(resolve => { releaseRestore = resolve })
|
|
await page.route(`**/api/v1/habits/${archived.id}/restore`, async route => { await restoreGate; await route.continue() })
|
|
await dialog.getByRole('button', { name: '恢复习惯' }).click()
|
|
const overlay = (page.viewportSize()?.width ?? 0) <= 930
|
|
? page.locator('.app-sheet-mask').filter({ has: dialog })
|
|
: dialog
|
|
await expect(overlay).toHaveAttribute('aria-busy', 'true')
|
|
const closeButton = dialog.getByRole('button', { name: '关闭习惯详情' })
|
|
const deleteButton = dialog.getByRole('button', { name: '永久删除' })
|
|
const restoreButton = dialog.getByRole('button', { name: '恢复习惯' })
|
|
await expect(closeButton).toBeDisabled()
|
|
await expect(deleteButton).toBeDisabled()
|
|
await expect(restoreButton).toBeDisabled()
|
|
await closeButton.click({ force: true })
|
|
await expect(dialog).toBeVisible()
|
|
if ((page.viewportSize()?.width ?? 0) <= 930) await overlay.click({ position: { x: 2, y: 2 }, force: true })
|
|
await expect(dialog).toBeVisible()
|
|
await page.keyboard.press('Escape')
|
|
await expect(dialog).toBeVisible()
|
|
|
|
// Desktop same-view navigation must force-close a busy detail even though user dismissal stays locked.
|
|
if ((page.viewportSize()?.width ?? 0) > 930) {
|
|
await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click()
|
|
await expect(dialog).toBeHidden()
|
|
await expect(shell).not.toHaveClass(/detail-open/)
|
|
}
|
|
releaseRestore()
|
|
await expect(dialog).toBeHidden()
|
|
await expect(page.locator('.habit-row').filter({ hasText: archivedName })).toBeVisible()
|
|
})
|