import type { APIRequestContext, 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 openTrash(page: Page) { if ((await page.viewportSize())!.width <= 930) { await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click() } await page.locator('.sidebar').getByRole('button', { name: '回收站', exact: true }).click() } test('Trash uses compact deadline groups without exposing child-level actions', async ({ page, request, baseURL }) => { const suffix = `${test.info().project.name}-${Date.now()}` 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 createDeleted = async (title: string, dueAt?: string) => { const created = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title, list_id: inbox.id, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) }, }) expect(created.ok(), await created.text()).toBeTruthy() const task = await created.json() as { id: string } expect((await mutate(request, baseURL!, `/api/v1/tasks/${task.id}`, { method: 'DELETE' })).ok()).toBeTruthy() return task } const overdueTitle = `回收站过期-${suffix}` const futureTitle = `回收站未来-${suffix}` const undatedTitle = `回收站无日期-${suffix}` await createDeleted(overdueTitle, '2026-01-02T15:59:00.000Z') await createDeleted(futureTitle, '2099-12-30T15:59:00.000Z') await createDeleted(undatedTitle) await page.goto('/') await openTrash(page) await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。') for (const group of ['已过期', '未来截止', '无截止日期']) { await expect(page.getByRole('heading', { name: group, exact: true })).toBeVisible() } for (const title of [overdueTitle, futureTitle, undatedTitle]) { const row = page.locator('.task-row--trash').filter({ hasText: title }) await expect(row).toHaveCount(1) await expect(row.getByRole('button', { name: '恢复' })).toBeVisible() await expect(row.getByRole('button', { name: '打开任务操作' })).toBeVisible() expect(await row.locator('.task-check').count()).toBe(0) } const overdueRow = page.locator('.task-row--trash').filter({ hasText: overdueTitle }) await expect(overdueRow.locator('.task-tail')).toBeVisible() const actionTrigger = overdueRow.getByRole('button', { name: '打开任务操作' }) await actionTrigger.focus() await actionTrigger.press('Enter') const menu = page.getByRole('menu', { name: '回收站任务操作' }) await expect(menu).toBeVisible() await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() await menu.getByRole('menuitem', { name: '永久删除' }).press('Tab') await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() await menu.getByRole('menuitem', { name: '永久删除' }).press('Shift+Tab') await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() await menu.getByRole('menuitem', { name: '永久删除' }).press('Escape') await expect(menu).toBeHidden() await expect(actionTrigger).toBeFocused() await actionTrigger.click() await page.getByRole('menuitem', { name: '永久删除' }).click() const dialog = page.getByRole('dialog') await expect(dialog).toContainText(`输入任务名称“${overdueTitle}”确认`) await dialog.getByRole('button', { name: '永久删除' }).click() await expect(dialog.getByRole('alert')).toContainText('任务名称不匹配') await dialog.getByRole('button', { name: '取消' }).click() await expect(actionTrigger).toBeFocused() const geometry = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth, controls: [...document.querySelectorAll('.trash-list button')].map(button => { const rect = button.getBoundingClientRect() return { width: rect.width, height: rect.height } }), })) expect(geometry.document).toBe(geometry.viewport) for (const control of geometry.controls) { expect(control.width).toBeGreaterThanOrEqual(44) expect(control.height).toBeGreaterThanOrEqual(44) } await actionTrigger.click() await page.getByRole('menuitem', { name: '永久删除' }).click() await dialog.getByLabel(`输入任务名称“${overdueTitle}”确认`).fill(overdueTitle) allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`) await dialog.getByRole('button', { name: '永久删除' }).click() await expect(overdueRow).toHaveCount(0) await expect(page.getByRole('heading', { name: '回收站', exact: true })).toBeFocused() })