feat: refine task and countdown interactions
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-17 09:32:22 +08:00
parent bdf2a53fa9
commit 1571c3946f
14 changed files with 388 additions and 31 deletions
+212
View File
@@ -0,0 +1,212 @@
import type { APIRequestContext, Locator, Page, Request } from '@playwright/test'
import { allowExpectedError, expect, test } from './fixtures'
type TaskFixture = { id: string; title: string; due_at: string | null; due_has_time: boolean; version: number }
type RecurrenceFixture = { id: string; task_id: string; rrule: string | null }
type Mutation = { method: string; pathname: string; body: Record<string, unknown> | null }
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 inboxId(request: APIRequestContext) {
const response = await request.get('/api/v1/bootstrap')
expect(response.ok(), await response.text()).toBeTruthy()
const inbox = (await response.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
return inbox.id as string
}
async function createTask(
request: APIRequestContext,
baseURL: string,
title: string,
options: { dueAt?: string; recurrence?: 'daily' } = {},
) {
const response = await mutate(request, baseURL, '/api/v1/tasks', {
method: 'POST',
data: {
title,
list_id: await inboxId(request),
...(options.dueAt ? { due_at: options.dueAt, due_has_time: false } : {}),
...(options.recurrence === 'daily' ? { trigger_mode: 'scheduled', rrule: 'FREQ=DAILY;INTERVAL=1' } : {}),
},
})
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<TaskFixture>
}
async function openTaskDetail(page: Page, title: string) {
await page.goto('/')
const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
const menu = page.locator('.topbar').getByRole('button', { name: /菜单$/ })
const inboxBox = await inbox.boundingBox()
if (!inboxBox || inboxBox.x < 0 || inboxBox.x >= page.viewportSize()!.width) await menu.click()
await expect(inbox).toBeInViewport()
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 reopenTaskDetail(page: Page, title: string) {
await page.getByRole('button', { name: '关闭详情' }).click()
await expect(page.getByRole('dialog', { name: '任务详情' })).toBeHidden()
await page.reload()
return openTaskDetail(page, title)
}
function repeatSelect(detail: Locator) {
return detail.locator('label').filter({ hasText: /^重复/ }).locator('select')
}
function recordSaveMutations(page: Page, taskId: string) {
const mutations: Mutation[] = []
const listener = (request: Request) => {
const url = new URL(request.url())
if (!['PATCH', 'POST', 'DELETE'].includes(request.method())) return
if (url.pathname !== `/api/v1/tasks/${taskId}` && !url.pathname.startsWith('/api/v1/recurrences')) return
mutations.push({ method: request.method(), pathname: url.pathname, body: request.postDataJSON() ?? null })
}
page.on('request', listener)
return { mutations, stop: () => page.off('request', listener) }
}
async function clickSaveAndWait(page: Page, taskId: string, recurrenceWrite?: { method: 'POST' | 'PATCH' | 'DELETE'; path: string }) {
const responses = [
page.waitForResponse(response => response.url().includes(`/api/v1/tasks/${taskId}`) && response.request().method() === 'PATCH' && response.ok()),
]
if (recurrenceWrite) {
responses.push(page.waitForResponse(response => {
const url = new URL(response.url())
return url.pathname === recurrenceWrite.path && response.request().method() === recurrenceWrite.method && response.ok()
}))
}
await Promise.all([...responses, page.getByRole('button', { name: '保存更改' }).click()])
await expect(page.locator('.toast')).toContainText('已保存')
}
async function getRecurrence(request: APIRequestContext, taskId: string) {
const response = await request.get(`/api/v1/tasks/${taskId}/recurrence`)
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<RecurrenceFixture | null>
}
test('clearing recurring task deadline persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
const task = await createTask(request, baseURL!, `E2E 清除日期 ${testInfo.project.name}`, {
dueAt: '2030-06-15T23:59:00Z', recurrence: 'daily',
})
let detail = await openTaskDetail(page, task.title)
await expect(detail.locator('input[aria-label="截止日期"]')).not.toHaveValue('')
await expect(repeatSelect(detail)).toHaveValue('daily')
const capture = recordSaveMutations(page, task.id)
await detail.getByRole('button', { name: '清除截止日期' }).click()
await clickSaveAndWait(page, task.id)
capture.stop()
expect(capture.mutations).toHaveLength(1)
expect(capture.mutations[0]).toMatchObject({ method: 'PATCH', pathname: `/api/v1/tasks/${task.id}` })
expect(capture.mutations[0].body).toMatchObject({ due_at: null, due_has_time: false })
expect(capture.mutations.some(item => item.method === 'DELETE' && item.pathname.startsWith('/api/v1/recurrences/'))).toBe(false)
expect(await getRecurrence(request, task.id)).toBeNull()
detail = await reopenTaskDetail(page, task.title)
await expect(detail.locator('input[aria-label="截止日期"]')).toHaveValue('')
await expect(repeatSelect(detail)).toHaveValue('none')
})
test('choosing no repeat keeps the deadline and persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
const task = await createTask(request, baseURL!, `E2E 取消重复 ${testInfo.project.name}`, {
dueAt: '2030-06-16T23:59:00Z', recurrence: 'daily',
})
let detail = await openTaskDetail(page, task.title)
const originalDate = await detail.locator('input[aria-label="截止日期"]').inputValue()
const recurrence = await getRecurrence(request, task.id)
expect(recurrence).not.toBeNull()
const capture = recordSaveMutations(page, task.id)
await repeatSelect(detail).selectOption('none')
allowExpectedError(page, `requestfailed: DELETE http://127.0.0.1:5173/api/v1/recurrences/${recurrence!.id} net::ERR_ABORTED`)
await clickSaveAndWait(page, task.id, { method: 'DELETE', path: `/api/v1/recurrences/${recurrence!.id}` })
capture.stop()
expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([
`PATCH /api/v1/tasks/${task.id}`,
`DELETE /api/v1/recurrences/${recurrence!.id}`,
])
expect(capture.mutations[0].body).toMatchObject({ due_has_time: false })
expect(capture.mutations[0].body?.due_at).not.toBeNull()
expect(await getRecurrence(request, task.id)).toBeNull()
detail = await reopenTaskDetail(page, task.title)
await expect(repeatSelect(detail)).toHaveValue('none')
await expect(detail.locator('input[aria-label="截止日期"]')).toHaveValue(originalDate)
})
test('editing an existing rule to weekly persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
const task = await createTask(request, baseURL!, `E2E 修改重复 ${testInfo.project.name}`, {
dueAt: '2030-06-17T23:59:00Z', recurrence: 'daily',
})
let detail = await openTaskDetail(page, task.title)
const recurrence = await getRecurrence(request, task.id)
expect(recurrence).not.toBeNull()
const capture = recordSaveMutations(page, task.id)
await repeatSelect(detail).selectOption('weekly')
await clickSaveAndWait(page, task.id, { method: 'PATCH', path: `/api/v1/recurrences/${recurrence!.id}` })
capture.stop()
expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([
`PATCH /api/v1/tasks/${task.id}`,
`PATCH /api/v1/recurrences/${recurrence!.id}`,
])
expect(capture.mutations[1].body).toMatchObject({ trigger_mode: 'scheduled' })
expect(capture.mutations[1].body?.rrule).toContain('FREQ=WEEKLY')
expect((await getRecurrence(request, task.id))?.rrule).toContain('FREQ=WEEKLY')
detail = await reopenTaskDetail(page, task.title)
await expect(repeatSelect(detail)).toHaveValue('weekly')
await expect(detail.locator('.repeat-custom-fields')).toHaveCount(0)
})
test('adding a daily rule persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
const task = await createTask(request, baseURL!, `E2E 新增重复 ${testInfo.project.name}`, {
dueAt: '2030-06-18T23:59:00Z',
})
let detail = await openTaskDetail(page, task.title)
expect(await getRecurrence(request, task.id)).toBeNull()
const capture = recordSaveMutations(page, task.id)
await repeatSelect(detail).selectOption('daily')
await clickSaveAndWait(page, task.id, { method: 'POST', path: '/api/v1/recurrences' })
capture.stop()
expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([
`PATCH /api/v1/tasks/${task.id}`,
'POST /api/v1/recurrences',
])
expect(capture.mutations[1].body).toMatchObject({ task_id: task.id, trigger_mode: 'scheduled' })
expect(capture.mutations[1].body?.rrule).toContain('FREQ=DAILY')
expect((await getRecurrence(request, task.id))?.rrule).toContain('FREQ=DAILY')
detail = await reopenTaskDetail(page, task.title)
await expect(repeatSelect(detail)).toHaveValue('daily')
})