feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+115
View File
@@ -0,0 +1,115 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import type { APIRequestContext, Page } from '@playwright/test'
import { expect, test } from './fixtures'
import { unzipSync } from 'fflate'
function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function csrf(request: APIRequestContext, baseURL: string) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
?? state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value
?? ''
}
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
const token = await csrf(request, baseURL)
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': token, origin: baseURL } })
}
test('complete ZIP backup preflights and replace-restores task, habit history, countdown, and attachment bytes', async ({ page, request, baseURL }, testInfo) => {
const suffix = testInfo.project.name
const taskTitle = `E2E 备份任务 ${suffix}`
const habitName = `E2E 备份习惯 ${suffix}`
const countdownTitle = `E2E 备份倒数日 ${suffix}`
const bootstrap = await request.get('/api/v1/bootstrap')
expect(bootstrap.ok()).toBeTruthy()
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const task = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id } })
expect(task.ok()).toBeTruthy()
const taskData = await task.json() as { id: string; version: number }
const attachmentName = `原始附件-${suffix}.txt`
const attachmentBytes = Buffer.from([0, 1, 2, 3, 10, 13, 127, 128, 254, 255])
const attachmentResponse = await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}/attachments`, {
method: 'POST', multipart: { file: { name: attachmentName, mimeType: 'text/plain', buffer: attachmentBytes } },
})
expect(attachmentResponse.ok()).toBeTruthy()
const attachment = await attachmentResponse.json() as { id: string; filename: string; size: number; mime_type: string }
expect(attachment).toMatchObject({ filename: attachmentName, size: attachmentBytes.length, mime_type: 'text/plain' })
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { method: 'POST', data: { name: habitName, kind: 'numeric', target: 2, max_value: 3, schedule_type: 'daily' } })
expect(habitResponse.ok()).toBeTruthy()
const habit = await habitResponse.json()
const day = new Date().toLocaleDateString('sv-SE')
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 2 } })).ok()).toBeTruthy()
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false } })
expect(countdownResponse.ok()).toBeTruthy()
await page.goto('/')
await bottomTab(page, '设置').click()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: '导出 ZIP' }).click()
const download = await downloadPromise
const zipPath = await download.path()
expect(zipPath).not.toBeNull()
const zipBytes = new Uint8Array(await readFile(zipPath!))
const files = unzipSync(zipBytes)
const manifest = JSON.parse(new TextDecoder().decode(files['manifest.json'])) as { format: string; version: number; entities: Record<string, number>; checksums: Record<string, string> }
expect(manifest.format).toBe('dodo-backup')
expect(manifest.version).toBe(2)
expect(manifest.entities.tasks).toBeGreaterThan(0)
expect(manifest.entities.habit_logs).toBeGreaterThan(0)
expect(manifest.entities.attachments).toBeGreaterThan(0)
const attachmentRows = JSON.parse(new TextDecoder().decode(files['data/attachments.json'])) as Array<{ id: string; task_id: string; filename: string; mime_type: string; size: number; archive_path: string }>
const archivedAttachment = attachmentRows.find(item => item.id === attachment.id)
expect(archivedAttachment).toMatchObject({ task_id: taskData.id, filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
expect(Buffer.from(files[archivedAttachment!.archive_path])).toEqual(attachmentBytes)
expect(manifest.checksums[archivedAttachment!.archive_path]).toBe(createHash('sha256').update(attachmentBytes).digest('hex'))
for (const [entry, digest] of Object.entries(manifest.checksums)) {
expect(files[entry], `declared ZIP entry ${entry}`).toBeTruthy()
expect(createHash('sha256').update(files[entry]).digest('hex')).toBe(digest)
}
expect((await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}`, { method: 'PATCH', data: { title: `${taskTitle} 已破坏`, version: taskData.version } })).ok()).toBeTruthy()
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 0 } })).ok()).toBeTruthy()
expect((await mutate(request, baseURL!, `/api/v1/countdowns/${(await countdownResponse.json()).id}`, { method: 'DELETE' })).ok()).toBeTruthy()
expect((await mutate(request, baseURL!, `/api/v1/attachments/${attachment.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
expect(await (await request.get(`/api/v1/tasks/${taskData.id}/attachments`)).json()).toEqual([])
const chooser = page.locator('input[type=file]')
await chooser.setInputFiles({ name: 'dodo-backup-v2.zip', mimeType: 'application/zip', buffer: Buffer.from(zipBytes) })
await page.getByLabel('恢复方式').selectOption('replace')
await page.getByRole('button', { name: '开始预检' }).click()
const preflight = page.locator('.backup-preflight')
await expect(preflight).toContainText('预检通过')
await expect(preflight).toContainText('附件')
await page.getByRole('button', { name: '替换并恢复' }).click()
const confirm = page.getByRole('dialog', { name: '确认替换全部数据?' })
await confirm.getByRole('button', { name: '确认', exact: true }).click()
await expect(page.getByRole('status')).toContainText('数据已恢复')
const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`)
expect(restoredTasksResponse.ok()).toBeTruthy()
const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }>
expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1)
const restoredTask = restoredTasks.find(item => item.title === taskTitle)!
const restoredAttachmentsResponse = await request.get(`/api/v1/tasks/${restoredTask.id}/attachments`)
expect(restoredAttachmentsResponse.ok()).toBeTruthy()
const restoredAttachments = await restoredAttachmentsResponse.json() as Array<{ id: string; filename: string; mime_type: string; size: number }>
expect(restoredAttachments).toHaveLength(1)
expect(restoredAttachments[0]).toMatchObject({ filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
const restoredBlob = await request.get(`/api/v1/attachments/${restoredAttachments[0].id}`)
expect(restoredBlob.ok()).toBeTruthy()
expect(Buffer.from(await restoredBlob.body())).toEqual(attachmentBytes)
await bottomTab(page, '习惯').click()
const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
await expect(page.locator('.habit-history__row')).toContainText('2 / 2')
await page.getByRole('button', { name: '关闭习惯详情' }).click()
await bottomTab(page, '倒数日').click()
await expect(page.getByText(countdownTitle, { exact: true })).toHaveCount(1)
})
+29
View File
@@ -0,0 +1,29 @@
import { expect, test as base } from '@playwright/test'
export const expectedErrors = new WeakMap<object, Set<string>>()
export function allowExpectedError(page: object, fragment: string) {
expectedErrors.get(page)?.add(fragment)
}
export const test = base.extend({
page: async ({ page }, use) => {
const failures: string[] = []
const allowed = new Set<string>()
expectedErrors.set(page, allowed)
const record = (message: string) => {
if (![...allowed].some(pattern => message.includes(pattern))) failures.push(message)
}
page.on('pageerror', error => record(`pageerror: ${error.message}`))
page.on('console', message => { if (message.type() === 'error') record(`console.error: ${message.text()}`) })
page.on('requestfailed', request => record(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`))
page.on('response', response => {
const url = new URL(response.url())
const baseURL = new URL(page.url() || 'http://127.0.0.1:5173')
if (url.origin === baseURL.origin && response.status() >= 400) record(`http ${response.status()}: ${response.request().method()} ${url.pathname}`)
})
await use(page)
expect(failures, 'unexpected browser/runtime errors').toEqual([])
},
})
export { expect }
+37
View File
@@ -0,0 +1,37 @@
import { chromium, type FullConfig } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
export default async function globalSetup(config: FullConfig) {
const projectName = process.env.DODO_E2E_PROJECT
if (!projectName) throw new Error('DODO_E2E_PROJECT is required')
const baseURL = config.projects[0]?.use.baseURL as string
const storageState = path.resolve('playwright-runtime', projectName, 'auth.json')
mkdirSync(path.dirname(storageState), { recursive: true })
const browser = await chromium.launch()
try {
const context = await browser.newContext({ baseURL })
const page = await context.newPage()
const readyDeadline = Date.now() + 90_000
let ready = false
while (Date.now() < readyDeadline) {
try {
const [health, frontend, proxy] = await Promise.all([
page.request.get('/health/ready'), page.request.get('/'), page.request.get('/api/v1/setup/status'),
])
if (health.ok() && frontend.ok() && proxy.ok()) { ready = true; break }
} catch {}
await new Promise(resolve => setTimeout(resolve, 250))
}
if (!ready) throw new Error('isolated frontend/backend/proxy did not become ready')
const initialized = await page.request.get('/api/v1/setup/status')
if (!initialized.ok()) throw new Error(`setup status failed: ${initialized.status()}`)
if ((await initialized.json()).initialized) throw new Error(`isolated ${projectName} runtime was already initialized`)
const response = await page.request.post('/api/v1/setup/initialize', { data: { username: `e2e-owner-${projectName}`, password: 'e2e-password-1234' } })
if (!response.ok()) throw new Error(`initialize failed: ${response.status()} ${await response.text()}`)
await context.storageState({ path: storageState })
} finally {
await browser.close()
}
}
+112
View File
@@ -0,0 +1,112 @@
import type { Locator, Page } from '@playwright/test'
import { allowExpectedError, expect, test } from './fixtures'
async function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
function taskRow(page: Page, title: string) {
return page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) })
}
async function assertInsideViewport(locator: Locator, page: Page) {
const box = await locator.boundingBox()
const viewport = page.viewportSize()
expect(box).not.toBeNull()
expect(viewport).not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.y).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1)
}
test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => {
const title = `E2E 今日任务 ${testInfo.project.name}`
await page.goto('/')
await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page')
await page.getByRole('button', { name: '添加任务' }).click()
await page.getByLabel('任务名称').fill(title)
await page.getByRole('button', { name: '添加任务', exact: true }).click()
const row = taskRow(page, title)
await expect(row).toHaveCount(1)
const rowMain = row.locator('.task-main')
await rowMain.click()
const detail = page.getByRole('dialog', { name: '任务详情' })
await expect(detail).toBeVisible()
await assertInsideViewport(detail, page)
await page.getByRole('button', { name: '关闭详情' }).click()
await expect(rowMain).toBeFocused()
await row.getByRole('button', { name: `完成${title}` }).click()
await expect(row).toHaveClass(/done/)
await page.reload()
const persisted = taskRow(page, title)
await expect(persisted).toHaveCount(1)
await expect(persisted.getByRole('button', { name: `重新打开${title}` })).toBeVisible()
await persisted.getByRole('button', { name: `重新打开${title}` }).click()
await expect(persisted.getByRole('button', { name: `完成${title}` })).toBeVisible()
})
test('numeric habit records history, edits target, and continues', async ({ page }, testInfo) => {
const name = `E2E 数量习惯 ${testInfo.project.name}`
await page.goto('/')
await (await bottomTab(page, '习惯')).click()
await expect(await bottomTab(page, '习惯')).toHaveAttribute('aria-current', 'page')
await page.getByRole('button', { name: '添加习惯' }).click()
await page.getByLabel('新习惯名称').fill(name)
await page.getByLabel('习惯类型').selectOption('numeric')
await page.getByLabel('目标值').fill('2')
await page.getByRole('button', { name: '添加习惯', exact: true }).click()
const row = page.locator('.habit-row').filter({ hasText: name })
await expect(row).toHaveCount(1)
const check = row.getByRole('button', { name: `完成${name}一次` })
await check.click()
await expect(row).toContainText('1 / 2')
await check.click()
await expect(row).toContainText('2 / 2')
await row.getByRole('button', { name: `查看习惯详情:${name}` }).click()
const detail = page.getByRole('dialog', { name: name })
await expect(detail.getByRole('heading', { name: '历史记录' })).toBeVisible()
await expect(detail.locator('.habit-history__row')).toContainText('2 / 2')
await detail.getByRole('button', { name: '编辑习惯' }).click()
await page.getByLabel('目标值').fill('3')
await page.getByRole('button', { name: '保存修改' }).click()
await expect(row).toContainText('2 / 3')
await row.getByRole('button', { name: `完成${name}一次` }).click()
await expect(row).toContainText('3 / 3')
await page.reload()
const persistedRow = page.locator('.habit-row').filter({ hasText: name })
await expect(persistedRow).toContainText('3 / 3')
await persistedRow.getByRole('button', { name: `查看习惯详情:${name}` }).click()
const persistedDetail = page.getByRole('dialog', { name })
await expect(persistedDetail.locator('.habit-history__row')).toContainText('3 / 3')
})
test('countdown archive and restore keeps one entity after refresh', async ({ page }, testInfo) => {
const title = `E2E 倒数日 ${testInfo.project.name}`
await page.goto('/')
await (await bottomTab(page, '倒数日')).click()
await page.getByRole('button', { name: '添加倒数日' }).click()
await page.getByLabel('倒数日名称').fill(title)
await page.getByRole('button', { name: '保存', exact: true }).click()
const item = page.getByRole('button').filter({ hasText: title })
await expect(item).toHaveCount(1)
await item.click()
const detail = page.getByRole('dialog', { name: title })
await expect(detail).toBeVisible()
// The UI intentionally aborts its DELETE fetch after the 204 response while closing the detail sheet.
allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/countdowns/')
await Promise.all([
page.waitForResponse(response => response.url().includes(`/api/v1/countdowns/`) && response.request().method() === 'DELETE' && response.status() === 204),
detail.getByRole('button', { name: '归档' }).click(),
])
await page.getByRole('button', { name: /已归档(1/ }).click()
const archived = page.locator('.archived-countdowns article').filter({ hasText: title })
await expect(archived).toHaveCount(1)
await archived.getByRole('button', { name: '恢复' }).click()
await page.reload()
await expect(page.getByText(title, { exact: true })).toHaveCount(1)
})
+118
View File
@@ -0,0 +1,118 @@
import type { Page } from '@playwright/test'
import { expect, test } from './fixtures'
function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function openTaskComposer(page: Page) {
await page.getByRole('button', { name: '添加任务' }).click()
return page.getByRole('dialog', { name: /添加(?:今天)?任务/ })
}
test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => {
await page.goto('/')
await bottomTab(page, '设置').click()
await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
const groups = page.locator('.settings-group')
await expect(groups).toHaveCount(5)
const layout = await page.locator('.settings-sections').evaluate(element => {
const groups = [...element.querySelectorAll<HTMLElement>(':scope > .settings-group')]
return {
bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
gaps: groups.slice(1).map((group, index) => group.getBoundingClientRect().top - groups[index].getBoundingClientRect().bottom),
}
})
expect(layout.bodyOverflow).toBe(0)
expect(layout.gaps.every(gap => gap >= 0 && gap <= 20)).toBeTruthy()
const sessionButtons = page.getByRole('button', { name: /撤销会话|撤销其他会话/ })
for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) {
const box = await target.boundingBox()
expect(box).not.toBeNull()
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
}
for (const target of await sessionButtons.all()) {
const box = await target.boundingBox()
expect(box).not.toBeNull()
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
}
})
test('task, habit, countdown, memo, action and confirmation overlays share the modal contract', async ({ page }, testInfo) => {
await page.goto('/')
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
await expect(dialog).toBeVisible()
await expect(page.locator('#app')).toHaveAttribute('inert', '')
const metrics = await dialog.evaluate(element => {
const panel = element as HTMLElement
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
const footer = panel.querySelector<HTMLElement>('.app-sheet__footer, footer')
return {
horizontalOverflow: panel.scrollWidth - panel.clientWidth,
headerVisible: !header || header.getBoundingClientRect().top >= 0,
footerVisible: !footer || footer.getBoundingClientRect().bottom <= innerHeight + 1,
}
})
expect(metrics).toEqual({ horizontalOverflow: 0, headerVisible: true, footerVisible: true })
}
await openTaskComposer(page)
await assertModal(page.getByRole('dialog', { name: /添加(?:今天)?任务/ }))
await page.keyboard.press('Escape')
await bottomTab(page, '习惯').click()
await page.getByRole('button', { name: '添加习惯' }).click()
await assertModal(page.getByRole('dialog', { name: '添加习惯' }))
await page.keyboard.press('Escape')
await bottomTab(page, '倒数日').click()
await page.getByRole('button', { name: '添加倒数日' }).click()
await assertModal(page.getByRole('dialog', { name: '新建倒数日' }))
await page.keyboard.press('Escape')
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
await page.locator('.sidebar').getByRole('button', { name: '备忘录', exact: true }).click()
await page.getByRole('button', { name: '添加备忘录' }).click()
await assertModal(page.getByRole('dialog', { name: '备忘录详情' }))
await page.getByLabel('备忘录标题').fill(`未保存 ${testInfo.project.name}`)
await page.getByRole('button', { name: '关闭备忘录' }).click()
const confirmation = page.getByRole('dialog', { name: '放弃未保存的更改?' })
await assertModal(confirmation)
await confirmation.getByRole('button', { name: '取消' }).click()
await expect(page.getByRole('dialog', { name: '备忘录详情' })).toBeVisible()
await expect(page.getByLabel('备忘录标题')).toBeFocused()
})
test('AppSheet traps focus, Escape closes, and scrim owns outside hit testing', async ({ page }) => {
await page.goto('/')
const opener = page.getByRole('button', { name: '添加任务' })
await opener.focus()
const dialog = await openTaskComposer(page)
await expect(dialog).toBeVisible()
await expect(page.getByLabel('任务名称')).toBeFocused()
const background = page.locator('#app')
await expect(background).toHaveAttribute('aria-hidden', 'true')
await expect(background).toHaveAttribute('inert', '')
const geometry = await page.locator('.app-overlay').evaluate(element => {
const rect = element.getBoundingClientRect()
const hit = document.elementFromPoint(2, 2)
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, hitIsScrim: hit === element }
})
expect(geometry).toEqual({ x: 0, y: 0, width: page.viewportSize()!.width, height: page.viewportSize()!.height, hitIsScrim: true })
const focusable = dialog.locator('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])').filter({ visible: true })
const first = focusable.first()
const last = focusable.last()
await first.focus()
await page.keyboard.press('Shift+Tab')
await expect(last).toBeFocused()
await page.keyboard.press('Tab')
await expect(first).toBeFocused()
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
await expect(opener).toBeFocused()
await openTaskComposer(page)
await page.mouse.click(2, 2)
await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toBeHidden()
})