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 | 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[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 } 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 } 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') })