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[1]) { return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } }) } async function createFixture(request: APIRequestContext, baseURL: string, suffix: string) { const bootstrap = await request.get('/api/v1/bootstrap') expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy() const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) const title = `纸页顺流-${suffix}-${'很长的任务标题'.repeat(8)}` const parentResponse = await mutate(request, baseURL, '/api/v1/tasks', { method: 'POST', data: { title, description: `备注\n\n${'长内容 '.repeat(40)}`, list_id: inbox.id, due_at: '2030-06-18T08:30:00Z', due_has_time: true }, }) expect(parentResponse.ok(), await parentResponse.text()).toBeTruthy() const parent = await parentResponse.json() as { id: string } const childTitle = `子任务-${suffix}-/Users/example/${'unbroken-path/'.repeat(18)}file.txt` const childResponse = await mutate(request, baseURL, '/api/v1/tasks', { method: 'POST', data: { title: childTitle, list_id: inbox.id, parent_id: parent.id } }) expect(childResponse.ok(), await childResponse.text()).toBeTruthy() return { title, childTitle } } async function openDetail(page: Page, title: string) { await page.goto('/') const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }) if ((await page.viewportSize())!.width <= 930) await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click() await inbox.click() const row = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) }) await expect(row).toHaveCount(1) await row.locator('.task-main').click() const detail = page.getByRole('dialog', { name: '任务详情' }) await expect(detail).toBeVisible() await expect(detail.getByRole('button', { name: '保存更改' })).toBeEnabled() return detail } async function rect(locator: Locator) { const value = await locator.boundingBox() expect(value).not.toBeNull() return value! } test('task detail paper flow keeps approved responsive geometry and material', async ({ page, request, baseURL }, testInfo) => { const fixture = await createFixture(request, baseURL!, `${testInfo.project.name}-${Date.now()}`) const detail = await openDetail(page, fixture.title) const viewport = page.viewportSize()! const header = detail.locator('.detail-head') const body = detail.locator('.detail-form') const footer = detail.locator('.detail-actions') const dateTime = detail.locator('.task-detail-date-time') const nested = detail.locator('.subtask-detail,.after-completion-fields,.repeat-custom-fields') const metrics = await detail.evaluate((element: HTMLElement) => { const style = getComputedStyle(element) const childrenInside = [...element.querySelectorAll('.detail-form > *')].every(child => child.getBoundingClientRect().right <= element.getBoundingClientRect().right + 1) return { width: element.getBoundingClientRect().width, maxHeight: style.maxHeight, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, childrenInside } }) expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) expect(metrics.childrenInside).toBe(true) expect((await rect(header)).height).toBeCloseTo(58, 0) expect((await body.evaluate(el => parseFloat(getComputedStyle(el).paddingLeft)))).toBeCloseTo(18, 0) const nestedStyles = await nested.evaluateAll(elements => elements.map(element => { const s = getComputedStyle(element); return { radius: s.borderRadius, shadow: s.boxShadow, background: s.backgroundColor } })) for (const style of nestedStyles) { expect(style.radius).toBe('0px'); expect(style.shadow).toBe('none'); expect(style.background).toBe('rgba(0, 0, 0, 0)') } for (const button of await detail.locator('button').all()) { const box = await button.boundingBox() expect(box?.height ?? 0).toBeGreaterThanOrEqual(44) } const footerBox = await rect(footer) const detailBox = await rect(detail) expect(Math.abs((footerBox.y + footerBox.height) - (detailBox.y + detailBox.height))).toBeLessThanOrEqual(2) if (viewport.width >= 931) { expect(metrics.width).toBeCloseTo(350, 0) const columns = await dateTime.locator('.task-detail-field').evaluateAll(elements => elements.map(element => element.getBoundingClientRect())) expect(columns[0].top).toBeCloseTo(columns[1].top, 0) expect(columns[1].left - columns[0].right).toBeCloseTo(10, 0) } else { expect(metrics.width).toBeCloseTo(viewport.width, 0) expect(parseFloat(metrics.maxHeight)).toBeLessThanOrEqual(viewport.height * .88 + 1) const columns = await dateTime.locator('.task-detail-field').evaluateAll(elements => elements.map(element => element.getBoundingClientRect())) expect(columns[1].top).toBeGreaterThanOrEqual(columns[0].bottom) expect(columns[0].width).toBeCloseTo(columns[1].width, 0) } }) test('subtask removal keeps the parent detail open and removes only the child', async ({ page, request, baseURL }, testInfo) => { const fixture = await createFixture(request, baseURL!, `remove-${testInfo.project.name}-${Date.now()}`) const detail = await openDetail(page, fixture.title) const child = detail.locator('.subtask-detail').filter({ hasText: fixture.childTitle }) await expect(child).toHaveCount(1) await child.getByRole('button', { name: `删除子任务${fixture.childTitle}` }).click() const confirm = page.getByRole('dialog', { name: /删除子任务/ }) allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/tasks/') await confirm.getByRole('button', { name: '确认' }).click() await expect(child).toHaveCount(0) await expect(detail).toBeVisible() await expect(detail.locator('textarea[aria-label="任务标题"]')).toHaveValue(fixture.title) })