自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器 - 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作 - 空间(标签)有独立地址,增删改通过接口同步到所有设备 - 口令登录(签名 Cookie 会话、登录限流、同源校验) - 图片上传到服务器,过大照片上传前自动压缩 - 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备 - 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush) - 选中样式与拖拽性能优化:拖动结束后才同步位置 - Docker / docker-compose 部署,README 与 CLAUDE.md 更新
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { expect, type Browser, type Page } from '@playwright/test'
|
||||
|
||||
export const E2E_PASSWORD = 'e2e-password'
|
||||
|
||||
export async function login(page: Page, path = '/') {
|
||||
await page.goto(path)
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
await page.getByLabel('登录口令').fill(E2E_PASSWORD)
|
||||
await page.getByLabel('登录口令').press('Enter')
|
||||
await expect(page).toHaveURL(/\/s\//)
|
||||
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
|
||||
}
|
||||
|
||||
/** 新开一个独立的浏览器上下文,相当于另一台设备 */
|
||||
export async function openDevice(browser: Browser, path = '/') {
|
||||
const context = await browser.newContext({ reducedMotion: 'reduce', locale: 'zh-CN' })
|
||||
const page = await context.newPage()
|
||||
await login(page, path)
|
||||
return { context, page }
|
||||
}
|
||||
|
||||
/** 在当前空间正中新建一张便笺并改标题 */
|
||||
export async function createNote(page: Page, title: string) {
|
||||
const before = await page.locator('.note-card').count()
|
||||
await page.getByRole('button', { name: '新便笺' }).click()
|
||||
await expect(page.locator('.note-card')).toHaveCount(before + 1)
|
||||
const card = page.locator('.note-card--selected')
|
||||
await card.locator('.note-card__title').fill(title)
|
||||
return card
|
||||
}
|
||||
|
||||
/** 当前画布上所有卡片的标题(输入框的值不会出现在 DOM 属性里,只能这样读) */
|
||||
export function noteTitles(page: Page) {
|
||||
return page
|
||||
.locator('.note-card__title')
|
||||
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
export async function expectNoteTitle(page: Page, title: string, present = true) {
|
||||
const poll = expect.poll(() => noteTitles(page), { timeout: 10_000 })
|
||||
if (present) await poll.toContain(title)
|
||||
else await poll.not.toContain(title)
|
||||
}
|
||||
|
||||
export async function noteByTitle(page: Page, title: string) {
|
||||
await expectNoteTitle(page, title)
|
||||
const index = (await noteTitles(page)).indexOf(title)
|
||||
return page.locator('.note-card').nth(index)
|
||||
}
|
||||
|
||||
/** 新建一个空间并切换过去,避免用例之间互相影响 */
|
||||
export async function createSpace(page: Page, name: string) {
|
||||
await page.getByRole('button', { name: '创建空间' }).click()
|
||||
await page.getByPlaceholder('例如:灵感收纳盒、工作面板…').fill(name)
|
||||
await page.getByRole('button', { name: '立即创建' }).click()
|
||||
await expect(page.locator('.space-title')).toContainText(name)
|
||||
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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()
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// See here how to get started:
|
||||
// https://playwright.dev/docs/intro
|
||||
test('opens the canvas and creates a note', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('无界笺', { exact: true })).toBeVisible()
|
||||
const notesBefore = await page.locator('.note-card').count()
|
||||
await page.getByRole('button', { name: '新便笺' }).click()
|
||||
await expect(page.locator('.note-card')).toHaveCount(notesBefore + 1)
|
||||
})
|
||||
|
||||
test('pastes clipboard text into a new note on the canvas', async ({ page }) => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('无界笺', { exact: true })).toBeVisible()
|
||||
const notesBefore = await page.locator('.note-card').count()
|
||||
|
||||
await page.evaluate(() =>
|
||||
navigator.clipboard.writeText('临时灵感\n把复制的文字直接变成一张新便笺。'),
|
||||
)
|
||||
await page.locator('.vue-flow__pane').click({ position: { x: 90, y: 90 } })
|
||||
await page.keyboard.press('Control+V')
|
||||
|
||||
await expect(page.locator('.note-card')).toHaveCount(notesBefore + 1)
|
||||
const pastedCard = page.locator('.note-card').last()
|
||||
await expect(pastedCard.locator('.note-card__title')).toHaveValue('临时灵感')
|
||||
await expect(pastedCard.locator('.note-card__content')).toHaveValue(
|
||||
'临时灵感\n把复制的文字直接变成一张新便笺。',
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user