308 lines
8.9 KiB
TypeScript
308 lines
8.9 KiB
TypeScript
/**
|
|
* E2E Tests for Real-time Collaboration via Automerge CRDT Sync
|
|
*
|
|
* Tests verify:
|
|
* - Two browsers see the same canvas state
|
|
* - Changes sync between clients in real-time
|
|
* - Shapes created by one client appear for others
|
|
* - Offline changes merge correctly when reconnecting
|
|
*/
|
|
|
|
import { test, expect, Page, BrowserContext } from '@playwright/test'
|
|
|
|
// Helper to wait for tldraw canvas to be ready
|
|
async function waitForCanvas(page: Page) {
|
|
// Wait for the tldraw editor to be mounted
|
|
await page.waitForSelector('.tl-container', { timeout: 30000 })
|
|
// Give it a moment to fully initialize
|
|
await page.waitForTimeout(1000)
|
|
}
|
|
|
|
// Helper to get unique room ID for test isolation
|
|
function getTestRoomId() {
|
|
return `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
}
|
|
|
|
// Helper to create a shape on the canvas
|
|
async function createRectangle(page: Page, x: number, y: number) {
|
|
// Select rectangle tool from toolbar
|
|
await page.click('[data-testid="tools.rectangle"]').catch(() => {
|
|
// Fallback: try keyboard shortcut
|
|
return page.keyboard.press('r')
|
|
})
|
|
|
|
// Click and drag to create shape
|
|
const canvas = page.locator('.tl-canvas')
|
|
await canvas.click({ position: { x, y } })
|
|
await page.waitForTimeout(500)
|
|
}
|
|
|
|
// Helper to count shapes on canvas
|
|
async function getShapeCount(page: Page): Promise<number> {
|
|
// Count shape elements in the DOM
|
|
const shapes = await page.locator('.tl-shape').count()
|
|
return shapes
|
|
}
|
|
|
|
// Helper to wait for sync (connection indicator shows connected)
|
|
async function waitForConnection(page: Page) {
|
|
// Wait for the connection status to show connected
|
|
// The app shows different states: connecting, connected, offline
|
|
await page.waitForFunction(() => {
|
|
const indicator = document.querySelector('[class*="connection"]')
|
|
return indicator?.textContent?.toLowerCase().includes('connected') ||
|
|
!document.querySelector('[class*="offline"]')
|
|
}, { timeout: 10000 }).catch(() => {
|
|
// If no indicator, assume connected after delay
|
|
return page.waitForTimeout(2000)
|
|
})
|
|
}
|
|
|
|
test.describe('Real-time Collaboration', () => {
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
let roomId: string
|
|
|
|
test.beforeEach(() => {
|
|
roomId = getTestRoomId()
|
|
})
|
|
|
|
test('canvas loads and displays connection status', async ({ page }) => {
|
|
await page.goto(`/board/${roomId}`)
|
|
await waitForCanvas(page)
|
|
|
|
// Verify tldraw container is present
|
|
await expect(page.locator('.tl-container')).toBeVisible()
|
|
|
|
// Verify canvas is interactive
|
|
await expect(page.locator('.tl-canvas')).toBeVisible()
|
|
})
|
|
|
|
test('two browsers see the same initial canvas', async ({ browser }) => {
|
|
// Create two independent browser contexts (like two different users)
|
|
const context1 = await browser.newContext()
|
|
const context2 = await browser.newContext()
|
|
|
|
const page1 = await context1.newPage()
|
|
const page2 = await context2.newPage()
|
|
|
|
try {
|
|
// Both navigate to the same room
|
|
await Promise.all([
|
|
page1.goto(`/board/${roomId}`),
|
|
page2.goto(`/board/${roomId}`)
|
|
])
|
|
|
|
// Wait for both canvases to load
|
|
await Promise.all([
|
|
waitForCanvas(page1),
|
|
waitForCanvas(page2)
|
|
])
|
|
|
|
// Wait for sync connection
|
|
await Promise.all([
|
|
waitForConnection(page1),
|
|
waitForConnection(page2)
|
|
])
|
|
|
|
// Both should have empty canvases initially (or same shapes if room exists)
|
|
const count1 = await getShapeCount(page1)
|
|
const count2 = await getShapeCount(page2)
|
|
|
|
// Should be the same (both see same state)
|
|
expect(count1).toBe(count2)
|
|
} finally {
|
|
await context1.close()
|
|
await context2.close()
|
|
}
|
|
})
|
|
|
|
test('shape created by one client appears for another', async ({ browser }) => {
|
|
const context1 = await browser.newContext()
|
|
const context2 = await browser.newContext()
|
|
|
|
const page1 = await context1.newPage()
|
|
const page2 = await context2.newPage()
|
|
|
|
try {
|
|
// Both navigate to the same room
|
|
await Promise.all([
|
|
page1.goto(`/board/${roomId}`),
|
|
page2.goto(`/board/${roomId}`)
|
|
])
|
|
|
|
await Promise.all([
|
|
waitForCanvas(page1),
|
|
waitForCanvas(page2)
|
|
])
|
|
|
|
await Promise.all([
|
|
waitForConnection(page1),
|
|
waitForConnection(page2)
|
|
])
|
|
|
|
// Get initial shape count
|
|
const initialCount = await getShapeCount(page1)
|
|
|
|
// Page 1 creates a shape
|
|
await createRectangle(page1, 200, 200)
|
|
|
|
// Wait for sync
|
|
await page1.waitForTimeout(2000)
|
|
|
|
// Page 2 should see the new shape
|
|
await page2.waitForFunction(
|
|
(expected) => document.querySelectorAll('.tl-shape').length > expected,
|
|
initialCount,
|
|
{ timeout: 10000 }
|
|
)
|
|
|
|
const count1 = await getShapeCount(page1)
|
|
const count2 = await getShapeCount(page2)
|
|
|
|
// Both should have the same number of shapes
|
|
expect(count2).toBe(count1)
|
|
expect(count1).toBeGreaterThan(initialCount)
|
|
} finally {
|
|
await context1.close()
|
|
await context2.close()
|
|
}
|
|
})
|
|
|
|
test('changes persist after page reload', async ({ page }) => {
|
|
await page.goto(`/board/${roomId}`)
|
|
await waitForCanvas(page)
|
|
await waitForConnection(page)
|
|
|
|
// Create a shape
|
|
await createRectangle(page, 150, 150)
|
|
await page.waitForTimeout(2000) // Wait for sync
|
|
|
|
const countBefore = await getShapeCount(page)
|
|
|
|
// Reload the page
|
|
await page.reload()
|
|
await waitForCanvas(page)
|
|
await waitForConnection(page)
|
|
|
|
// Shape should still be there
|
|
const countAfter = await getShapeCount(page)
|
|
expect(countAfter).toBe(countBefore)
|
|
})
|
|
|
|
test('concurrent edits from multiple clients merge correctly', async ({ browser }) => {
|
|
const context1 = await browser.newContext()
|
|
const context2 = await browser.newContext()
|
|
|
|
const page1 = await context1.newPage()
|
|
const page2 = await context2.newPage()
|
|
|
|
try {
|
|
await Promise.all([
|
|
page1.goto(`/board/${roomId}`),
|
|
page2.goto(`/board/${roomId}`)
|
|
])
|
|
|
|
await Promise.all([
|
|
waitForCanvas(page1),
|
|
waitForCanvas(page2)
|
|
])
|
|
|
|
await Promise.all([
|
|
waitForConnection(page1),
|
|
waitForConnection(page2)
|
|
])
|
|
|
|
const initialCount = await getShapeCount(page1)
|
|
|
|
// Both clients create shapes simultaneously
|
|
await Promise.all([
|
|
createRectangle(page1, 100, 100),
|
|
createRectangle(page2, 300, 300)
|
|
])
|
|
|
|
// Wait for sync to complete
|
|
await page1.waitForTimeout(3000)
|
|
await page2.waitForTimeout(3000)
|
|
|
|
// Both clients should see both shapes
|
|
const count1 = await getShapeCount(page1)
|
|
const count2 = await getShapeCount(page2)
|
|
|
|
// Both should have 2 more shapes than initial
|
|
expect(count1).toBeGreaterThanOrEqual(initialCount + 2)
|
|
expect(count1).toBe(count2)
|
|
} finally {
|
|
await context1.close()
|
|
await context2.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('Offline Sync Recovery', () => {
|
|
test('client reconnects and syncs after going offline', async ({ page, context }) => {
|
|
const roomId = getTestRoomId()
|
|
|
|
await page.goto(`/board/${roomId}`)
|
|
await waitForCanvas(page)
|
|
await waitForConnection(page)
|
|
|
|
// Create initial shape while online
|
|
await createRectangle(page, 100, 100)
|
|
await page.waitForTimeout(2000)
|
|
|
|
const countOnline = await getShapeCount(page)
|
|
|
|
// Go offline
|
|
await context.setOffline(true)
|
|
await page.waitForTimeout(1000)
|
|
|
|
// Create shape while offline
|
|
await createRectangle(page, 200, 200)
|
|
await page.waitForTimeout(1000)
|
|
|
|
// Shape should be visible locally
|
|
const countOffline = await getShapeCount(page)
|
|
expect(countOffline).toBe(countOnline + 1)
|
|
|
|
// Go back online
|
|
await context.setOffline(false)
|
|
await page.waitForTimeout(3000) // Wait for sync
|
|
|
|
// Shape should still be there after reconnect
|
|
const countReconnected = await getShapeCount(page)
|
|
expect(countReconnected).toBe(countOffline)
|
|
})
|
|
})
|
|
|
|
test.describe('Connection Status UI', () => {
|
|
test('shows offline indicator when disconnected', async ({ page, context }) => {
|
|
const roomId = getTestRoomId()
|
|
|
|
await page.goto(`/board/${roomId}`)
|
|
await waitForCanvas(page)
|
|
|
|
// Go offline
|
|
await context.setOffline(true)
|
|
|
|
// Wait for offline state to be detected
|
|
await page.waitForTimeout(2000)
|
|
|
|
// Should show some indication of offline status
|
|
// Look for common offline indicators
|
|
const offlineIndicator = await page.locator('[class*="offline"], [class*="disconnected"], :text("Offline"), :text("Disconnected")').first()
|
|
|
|
// Check if any offline indicator is visible
|
|
const isOffline = await offlineIndicator.isVisible().catch(() => false)
|
|
|
|
// If no explicit indicator, check that we can still interact (offline-first mode)
|
|
if (!isOffline) {
|
|
// Canvas should still be usable offline
|
|
await expect(page.locator('.tl-canvas')).toBeVisible()
|
|
}
|
|
|
|
// Go back online
|
|
await context.setOffline(false)
|
|
})
|
|
})
|