feat: refine task and countdown interactions
This commit is contained in:
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user