feat: strengthen backup and mobile workflows
This commit is contained in:
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user