Files
boundless/e2e/sync.spec.ts
T
吴璨 fad9760265 自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
2026-10-21 18:09:03 +08:00

192 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from '@playwright/test'
import {
createNote,
createSpace,
expectNoteTitle,
login,
noteByTitle,
noteTitles,
openDevice,
} from './helpers'
test.describe('登录', () => {
test('未登录时跳转到登录页,口令错误会提示', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
await page.getByLabel('登录口令').fill('wrong-password')
await page.getByLabel('登录口令').press('Enter')
await expect(page.getByRole('alert')).toHaveText('口令不正确')
})
test('登录后看到初始空间,退出后需要重新登录', async ({ page }) => {
await login(page)
await expectNoteTitle(page, '欢迎来到无界笺')
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page).toHaveURL(/\/login/)
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
})
})
test.describe('多端同步', () => {
test('一台设备的修改会实时出现在另一台设备上', async ({ page, browser }) => {
await login(page)
await createSpace(page, '同步测试')
const spaceUrl = page.url()
const other = await openDevice(browser, spaceUrl)
await expect(other.page.locator('.space-title')).toContainText('同步测试')
// 新建
await createNote(page, '来自设备 A')
await expectNoteTitle(other.page, '来自设备 A')
// 修改内容
const remoteCard = await noteByTitle(other.page, '来自设备 A')
await remoteCard.locator('.note-card__content').fill('设备 B 补充的一句话')
const localCard = await noteByTitle(page, '来自设备 A')
await expect(localCard.locator('.note-card__content')).toHaveValue('设备 B 补充的一句话')
// 换色
await localCard.locator('.color-picker button[data-color="mint"]').click()
await expect(remoteCard).toHaveAttribute('data-color', 'mint')
// 删除
await localCard.locator('.card-icon-button').click()
await page.getByRole('button', { name: '删除' }).click()
await expectNoteTitle(other.page, '来自设备 A', false)
await other.context.close()
})
test('新建的空间会出现在其他设备的列表里', async ({ page, browser }) => {
await login(page)
const other = await openDevice(browser)
await createSpace(page, '新的标签')
await expect(other.page.locator('.space-list')).toContainText('新的标签')
await expect(page.locator('.devices__button')).toHaveText(/2/)
await other.context.close()
})
test('刷新页面后内容仍在', async ({ page }) => {
await login(page)
await createSpace(page, '持久化')
await createNote(page, '刷新也不会丢')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.reload()
await expectNoteTitle(page, '刷新也不会丢')
})
test('撤销只影响本机的操作', async ({ page, browser }) => {
await login(page)
await createSpace(page, '撤销测试')
const other = await openDevice(browser, page.url())
await createNote(other.page, '别人的便笺')
await expectNoteTitle(page, '别人的便笺')
await createNote(page, '我的便笺')
await page.locator('.vue-flow__pane').click({ position: { x: 40, y: 300 } })
await page.keyboard.press('Control+Z')
await page.keyboard.press('Control+Z')
await expectNoteTitle(page, '我的便笺', false)
await expectNoteTitle(page, '别人的便笺')
await expectNoteTitle(other.page, '我的便笺', false)
await other.context.close()
})
})
test.describe('画布操作', () => {
test('粘贴文字会生成一张新便笺', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await login(page)
await createSpace(page, '粘贴')
const before = await page.locator('.note-card').count()
await page.evaluate(() =>
navigator.clipboard.writeText('临时灵感\n把复制的文字直接变成一张新便笺。'),
)
await page.locator('.vue-flow__pane').click({ position: { x: 90, y: 300 } })
await page.keyboard.press('Control+V')
await expect(page.locator('.note-card')).toHaveCount(before + 1)
const card = await noteByTitle(page, '临时灵感')
await expect(card.locator('.note-card__content')).toHaveValue(
'临时灵感\n把复制的文字直接变成一张新便笺。',
)
})
test('上传图片后保存在服务器上', async ({ page }) => {
await login(page)
await createSpace(page, '图片')
// 1×1 PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
await page.locator('input[type="file"]').setInputFiles({
name: '小图.png',
mimeType: 'image/png',
buffer: png,
})
const image = page.locator('.note-card__image-wrap img')
await expect(image).toHaveAttribute('src', /^\/api\/images\/[a-f0-9]{40}\.png$/)
const response = await page.request.get((await image.getAttribute('src'))!)
expect(response.status()).toBe(200)
expect(response.headers()['content-type']).toBe('image/png')
})
test('全局搜索可以跳到其他空间的卡片', async ({ page }) => {
await login(page)
await createSpace(page, '搜索来源')
await createNote(page, '藏在别处的萤火虫')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await createSpace(page, '搜索起点')
await page.getByLabel('搜索便笺').fill('萤火虫')
await page.getByRole('button', { name: /藏在别处的萤火虫/ }).click()
await expect(page.locator('.space-title')).toContainText('搜索来源')
await expect(page.locator('.note-card--selected .note-card__title')).toHaveValue(
'藏在别处的萤火虫',
)
})
})
test.describe('历史版本', () => {
test('可以恢复到之前保存的版本', async ({ page }) => {
await login(page)
await createSpace(page, '版本')
await createNote(page, '想留住的想法')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.getByRole('button', { name: '历史版本' }).click()
await page.getByRole('button', { name: '保存当前版本' }).click()
await expect(page.locator('.version-item', { hasText: '手动保存' })).toHaveCount(1)
await page.getByRole('button', { name: '完成' }).click()
const card = await noteByTitle(page, '想留住的想法')
await card.locator('.card-icon-button').click()
await page.getByRole('button', { name: '删除' }).click()
await expectNoteTitle(page, '想留住的想法', false)
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.getByRole('button', { name: '历史版本' }).click()
const version = page.locator('.version-item', { hasText: '手动保存' })
await version.getByRole('button', { name: '恢复' }).click()
await version.getByRole('button', { name: '确认恢复' }).click()
await expectNoteTitle(page, '想留住的想法')
expect(await noteTitles(page)).toHaveLength(1)
})
})
test('加载动画会随机作画', async ({ browser }) => {
const context = await browser.newContext({ reducedMotion: 'no-preference' })
const page = await context.newPage()
await page.goto('/login')
const frame = page.frameLocator('.painting__tile iframe')
await expect(frame.locator('canvas').first()).toBeAttached({ timeout: 15_000 })
await expect(page.locator('.painting__caption')).toHaveText(/ · /)
await context.close()
})