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 } }) } function bottomTab(page: Page, name: string) { return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) } async function openSidebarView(page: Page, name: string) { await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click() await page.locator('.sidebar').getByRole('button', { name, exact: true }).click() } async function expectNoHorizontalOverflow(page: Page, label: string, testInfo: { project: { name: string } }) { const metrics = await page.evaluate(() => ({ viewport: { width: innerWidth, height: innerHeight }, document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth }, body: { clientWidth: document.body.clientWidth, scrollWidth: document.body.scrollWidth }, })) console.log(`[qa-metrics][${testInfo.project.name}][${label}] ${JSON.stringify(metrics)}`) expect(metrics.document.scrollWidth).toBe(metrics.document.clientWidth) expect(metrics.body.scrollWidth).toBeLessThanOrEqual(metrics.body.clientWidth) } async function taskRow(page: Page, title: string) { const row = page.locator('.task-row').filter({ has: page.locator('.task-main strong', { hasText: title }) }) await expect(row).toHaveCount(1) return row } async function createTask(request: APIRequestContext, baseURL: string, title: string, listId: string, dueAt?: string) { const response = await mutate(request, baseURL, '/api/v1/tasks', { method: 'POST', data: { title, list_id: listId, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) }, }) expect(response.ok(), await response.text()).toBeTruthy() return response.json() as Promise<{ id: string }> } async function createCountdown(request: APIRequestContext, baseURL: string, title: string, eventDate: string, pinned = false) { const response = await mutate(request, baseURL, '/api/v1/countdowns', { method: 'POST', data: { title, event_date: eventDate, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false, pinned }, }) expect(response.ok(), await response.text()).toBeTruthy() return response.json() as Promise<{ id: string }> } async function box(locator: Locator) { const value = await locator.boundingBox() expect(value).not.toBeNull() return value! } test('task rows use the body for detail and Trash keeps distinct actions', async ({ page, request, baseURL }, testInfo) => { const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` 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 todayTitle = `验收今天超长任务标题-${suffix}-用于确认正文获得更多实际可用宽度` const inboxTitle = `验收清单任务-${suffix}` const restoreTitle = `验收恢复任务-${suffix}` const purgeTitle = `验收永久删除任务-${suffix}` await createTask(request, baseURL!, inboxTitle, inbox.id) const restoreCandidate = await createTask(request, baseURL!, restoreTitle, inbox.id) const purgeCandidate = await createTask(request, baseURL!, purgeTitle, inbox.id) expect((await mutate(request, baseURL!, `/api/v1/tasks/${restoreCandidate.id}`, { method: 'DELETE' })).ok()).toBeTruthy() expect((await mutate(request, baseURL!, `/api/v1/tasks/${purgeCandidate.id}`, { method: 'DELETE' })).ok()).toBeTruthy() await page.goto('/') await page.getByRole('button', { name: '添加任务' }).click() await page.getByLabel('任务名称').fill(todayTitle) await page.getByRole('button', { name: '添加任务', exact: true }).click() const todayRow = await taskRow(page, todayTitle) expect(await todayRow.locator('.task-detail-trigger').count()).toBe(0) const todayGeometry = await todayRow.evaluate(element => { const row = element.getBoundingClientRect() const main = element.querySelector('.task-main')!.getBoundingClientRect() return { row: { x: row.x, width: row.width, right: row.right }, main: { x: main.x, width: main.width, right: main.right }, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth } }) console.log(`[qa-metrics][${suffix}][today-task] ${JSON.stringify(todayGeometry)}`) expect(todayGeometry.main.width).toBeGreaterThan(200) expect(todayGeometry.scrollWidth).toBeLessThanOrEqual(todayGeometry.clientWidth) await todayRow.locator('.task-main').click() await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible() await page.getByRole('button', { name: '关闭详情' }).click() await openSidebarView(page, '收集箱') const inboxRow = await taskRow(page, inboxTitle) expect(await inboxRow.locator('.task-detail-trigger').count()).toBe(0) await inboxRow.locator('.task-main').click() await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible() await page.getByRole('button', { name: '关闭详情' }).click() await openSidebarView(page, '回收站') const restoreRow = await taskRow(page, restoreTitle) const purgeRow = await taskRow(page, purgeTitle) for (const deletedRow of [restoreRow, purgeRow]) { await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible() await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible() await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role') await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex') expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0) } await restoreRow.getByRole('button', { name: '恢复' }).click() await expect(restoreRow).toHaveCount(0) await purgeRow.getByRole('button', { name: '永久删除' }).click() const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` }) await expect(purgeDialog).toBeVisible() // The UI can abort the completed 204 request while the confirmation overlay closes. allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`) await purgeDialog.getByRole('button', { name: '确认', exact: true }).click() await expect(purgeRow).toHaveCount(0) await page.reload() await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0) await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(0) await openSidebarView(page, '收集箱') await expect(await taskRow(page, restoreTitle)).toHaveCount(1) await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(0) await expectNoHorizontalOverflow(page, 'tasks-trash', testInfo) }) test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => { await page.goto('/') await bottomTab(page, '设置').click() expect(await page.locator('.view-intro').count()).toBe(0) await expect(page.locator('.settings-group')).toHaveCount(4) expect(await page.locator('.settings-danger').count()).toBe(0) const input = page.locator('input[type=file]') await input.setInputFiles({ name: 'acceptance.zip', mimeType: 'application/zip', buffer: Buffer.from('zip-placeholder') }) await page.getByLabel('恢复方式').selectOption('replace') const warning = page.locator('.restore-replace-warning') await expect(warning).toBeVisible() const restoreRow = page.getByText('恢复方式', { exact: true }).locator('..').locator('..') const zipGeometry = { row: await box(restoreRow), warning: await box(warning) } console.log(`[qa-metrics][${testInfo.project.name}][settings-zip] ${JSON.stringify(zipGeometry)}`) expect(Math.abs(zipGeometry.warning.y - zipGeometry.row.y)).toBeLessThan(zipGeometry.row.height) await input.setInputFiles({ name: 'legacy.csv', mimeType: 'text/csv', buffer: Buffer.from('\ufefftitle\nlegacy') }) await expect(page.getByLabel('恢复方式')).toHaveValue('merge') await expect(page.getByLabel('恢复方式')).toBeDisabled() await expect(page.locator('.restore-replace-warning')).toHaveCount(0) await expect(page.locator('.backup-preflight.legacy')).not.toContainText(/替换现有数据|替换并恢复/) await expectNoHorizontalOverflow(page, 'settings', testInfo) }) test('Habits and Countdowns use reduced headers, compact rows, and continuous archive styling', async ({ page, request, baseURL }, testInfo) => { const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()) const focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true) await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day) const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day) expect((await mutate(request, baseURL!, `/api/v1/countdowns/${archived.id}`, { method: 'DELETE' })).ok()).toBeTruthy() expect(focus.id).toBeTruthy() await page.goto('/') await bottomTab(page, '习惯').click() expect(await page.locator('.view-intro').count()).toBe(0) await expectNoHorizontalOverflow(page, 'habits', testInfo) await bottomTab(page, '倒数日').click() await expect(page.locator('.countdown-summary')).toContainText(/\d+ 个重要日子/) expect(await page.locator('.countdown-hero').count()).toBe(0) const activeRow = page.locator('.countdown-row').filter({ hasText: `普通倒数-${suffix}` }) await expect(activeRow).toHaveCount(1) expect(await activeRow.locator('.countdown-main > small').count()).toBe(1) expect(await activeRow.locator('.countdown-badges, .pinned-icon').count()).toBe(0) const rowText = await activeRow.innerText() expect(rowText).not.toMatch(/农历|不重复|每周|每月|每年/) const toggle = page.getByRole('button', { name: /已归档(1)/ }) await expect(toggle).toHaveAttribute('aria-expanded', 'false') await expect(toggle).toHaveAttribute('aria-controls', 'archived-countdowns') const toggleBox = await box(toggle) expect(toggleBox.height).toBeGreaterThanOrEqual(44) await toggle.click() await expect(toggle).toHaveAttribute('aria-expanded', 'true') const archive = page.locator('#archived-countdowns') const archiveMetrics = await archive.evaluate(element => { const style = getComputedStyle(element) const articles = [...element.querySelectorAll('article')].map(article => { const itemStyle = getComputedStyle(article) return { borderRadius: itemStyle.borderRadius, boxShadow: itemStyle.boxShadow, borderTop: itemStyle.borderTopWidth, borderBottom: itemStyle.borderBottomWidth } }) const buttons = [...element.querySelectorAll('button')].map(button => ({ text: button.innerText, ...button.getBoundingClientRect().toJSON() })) return { container: { borderRadius: style.borderRadius, boxShadow: style.boxShadow }, articles, buttons } }) console.log(`[qa-metrics][${testInfo.project.name}][countdown-archive] ${JSON.stringify(archiveMetrics)}`) expect(archiveMetrics.container.boxShadow).toBe('none') expect(archiveMetrics.articles.every(item => item.borderRadius === '0px' && item.boxShadow === 'none')).toBeTruthy() expect(archiveMetrics.buttons.every(item => item.width >= 44 && item.height >= 44)).toBeTruthy() await expectNoHorizontalOverflow(page, 'countdowns', testInfo) })