canvas-website/tests/e2e/offline-mode.spec.ts

391 lines
11 KiB
TypeScript

/**
* E2E Tests for Offline Storage and Cold Reload
*
* Tests verify:
* - Canvas state persists to IndexedDB
* - Canvas loads from local storage on cold reload (offline)
* - Works completely offline after initial load
* - Sync resumes automatically when back online
*/
import { test, expect, Page } from '@playwright/test'
// Helper to wait for canvas to be ready
async function waitForCanvas(page: Page) {
await page.waitForSelector('.tl-container', { timeout: 30000 })
await page.waitForSelector('.tl-canvas', { timeout: 30000 })
// Wait for canvas to be interactive
await page.waitForTimeout(2000)
}
// Generate unique room ID
function getTestRoomId() {
return `offline-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
// Helper to create a shape
async function createShape(page: Page, x: number, y: number) {
await page.keyboard.press('r') // Rectangle tool shortcut
await page.waitForTimeout(300)
const canvas = page.locator('.tl-canvas')
await canvas.waitFor({ state: 'visible', timeout: 30000 })
await canvas.click({ position: { x, y } })
await page.waitForTimeout(500)
}
// Helper to draw freehand line
async function drawLine(page: Page, startX: number, startY: number, endX: number, endY: number) {
await page.keyboard.press('d') // Draw tool shortcut
await page.waitForTimeout(300)
const canvas = page.locator('.tl-canvas')
await canvas.waitFor({ state: 'visible', timeout: 30000 })
await canvas.hover({ position: { x: startX, y: startY } })
await page.mouse.down()
await canvas.hover({ position: { x: endX, y: endY } })
await page.mouse.up()
await page.waitForTimeout(500)
}
// Helper to count shapes
async function getShapeCount(page: Page): Promise<number> {
return await page.locator('.tl-shape').count()
}
// Helper to check IndexedDB has data
async function hasIndexedDBData(page: Page): Promise<boolean> {
return await page.evaluate(async () => {
try {
const databases = await indexedDB.databases()
// Check for automerge or canvas-related databases
return databases.some(db =>
db.name?.includes('automerge') ||
db.name?.includes('canvas') ||
db.name?.includes('document')
)
} catch {
return false
}
})
}
// Helper to wait for IndexedDB save
async function waitForIndexedDBSave(page: Page) {
// IndexedDB saves are debounced - wait for settle
await page.waitForTimeout(3000)
}
test.describe('Offline Storage', () => {
// These tests can be flaky in CI due to timing
test.describe.configure({ retries: 2 })
test('canvas state saves to IndexedDB', async ({ page }) => {
const roomId = getTestRoomId()
await page.goto(`/board/${roomId}`)
await waitForCanvas(page)
// Create some content
await createShape(page, 150, 150)
await createShape(page, 250, 250)
// Wait for save to IndexedDB
await waitForIndexedDBSave(page)
// Verify IndexedDB has data
const hasData = await hasIndexedDBData(page)
expect(hasData).toBe(true)
})
test('canvas loads from IndexedDB on cold reload', async ({ page, context }) => {
const roomId = getTestRoomId()
// First: Create content while online
await page.goto(`/board/${roomId}`)
await waitForCanvas(page)
await createShape(page, 100, 100)
await createShape(page, 200, 200)
await createShape(page, 300, 300)
// Wait for IndexedDB save
await waitForIndexedDBSave(page)
const shapeCountBefore = await getShapeCount(page)
expect(shapeCountBefore).toBeGreaterThanOrEqual(3)
// Reload the page (cold reload) - simulating browser restart
await page.reload()
// Wait for canvas to load from IndexedDB/server
await waitForCanvas(page)
// Shapes should be restored from local storage or server
const shapeCountAfter = await getShapeCount(page)
// Should have the same shapes (loaded from IndexedDB or synced)
expect(shapeCountAfter).toBe(shapeCountBefore)
})
test('works completely offline after initial load', async ({ page, context }) => {
const roomId = getTestRoomId()
// Load page online first
await page.goto(`/board/${roomId}`)
await waitForCanvas(page)
await page.waitForTimeout(2000) // Let sync complete
const initialCount = await getShapeCount(page)
// Go offline
await context.setOffline(true)
await page.waitForTimeout(1000)
// Create shapes while offline
await createShape(page, 100, 100)
await createShape(page, 200, 100)
await drawLine(page, 300, 100, 400, 200)
// Shapes should appear locally
const offlineCount = await getShapeCount(page)
expect(offlineCount).toBeGreaterThan(initialCount)
// Wait for local save
await waitForIndexedDBSave(page)
// Go back online to verify persistence
await context.setOffline(false)
await page.waitForTimeout(3000)
// Reload to verify content persisted (now that we're online)
await page.reload()
await waitForCanvas(page)
// Shapes should persist (from IndexedDB and/or sync)
const reloadedCount = await getShapeCount(page)
expect(reloadedCount).toBe(offlineCount)
})
test('sync resumes automatically when back online', async ({ page, context }) => {
const roomId = getTestRoomId()
await page.goto(`/board/${roomId}`)
await waitForCanvas(page)
await page.waitForTimeout(3000) // Initial sync - give more time
// Go offline
await context.setOffline(true)
await page.waitForTimeout(1000)
// Create content offline
await createShape(page, 150, 150)
await page.waitForTimeout(1000)
const offlineCount = await getShapeCount(page)
// Go back online
await context.setOffline(false)
// Wait for reconnection and sync - increase wait time
await page.waitForTimeout(7000)
// Content should still be there after sync
const onlineCount = await getShapeCount(page)
expect(onlineCount).toBe(offlineCount)
// Wait for canvas to be fully interactive after reconnection
await waitForCanvas(page)
// Verify sync worked by checking we can still interact
await createShape(page, 250, 250)
await page.waitForTimeout(1000) // Wait for shape to render
const finalCount = await getShapeCount(page)
expect(finalCount).toBeGreaterThan(onlineCount)
})
})
test.describe('Offline UI Indicators', () => {
test('shows appropriate status when offline', async ({ page, context }) => {
const roomId = getTestRoomId()
await page.goto(`/board/${roomId}`)
await waitForCanvas(page)
// Go offline
await context.setOffline(true)
await page.waitForTimeout(2000)
// Look for offline status indicators
// The app may show various indicators depending on implementation
const possibleIndicators = [
page.locator(':text("Offline")'),
page.locator(':text("Working Offline")'),
page.locator(':text("Disconnected")'),
page.locator('[class*="offline"]'),
page.locator('[class*="disconnected"]'),
]
// Check if any indicator is present
let foundIndicator = false
for (const indicator of possibleIndicators) {
try {
const count = await indicator.count()
if (count > 0 && await indicator.first().isVisible()) {
foundIndicator = true
break
}
} catch {
continue
}
}
// Either show explicit indicator OR continue working (offline-first)
if (!foundIndicator) {
// Canvas should still work offline
await createShape(page, 200, 200)
const count = await getShapeCount(page)
expect(count).toBeGreaterThan(0)
}
})
test('shows reconnection status when coming back online', async ({ page, context }) => {
const roomId = getTestRoomId()
await page.goto(`/board/${roomId}`)
await waitForCanvas(page)
await page.waitForTimeout(2000)
// Go offline then online
await context.setOffline(true)
await page.waitForTimeout(2000)
await context.setOffline(false)
// Wait for reconnection
await page.waitForTimeout(3000)
// Look for connected status or verify functionality works
const possibleConnectedIndicators = [
page.locator(':text("Connected")'),
page.locator('[class*="connected"]'),
page.locator('[class*="synced"]'),
]
let foundConnected = false
for (const indicator of possibleConnectedIndicators) {
try {
const count = await indicator.count()
if (count > 0 && await indicator.first().isVisible()) {
foundConnected = true
break
}
} catch {
continue
}
}
// Verify functionality works (can create and see shapes)
await createShape(page, 200, 200)
const count = await getShapeCount(page)
expect(count).toBeGreaterThan(0)
})
})
test.describe('Data Persistence Across Sessions', () => {
test('data persists when closing and reopening browser', async ({ browser }) => {
const roomId = getTestRoomId()
// Session 1: Create content
const context1 = await browser.newContext()
const page1 = await context1.newPage()
await page1.goto(`/board/${roomId}`)
await waitForCanvas(page1)
await createShape(page1, 100, 100)
await createShape(page1, 200, 200)
await waitForIndexedDBSave(page1)
const countSession1 = await getShapeCount(page1)
// Close first session
await context1.close()
// Session 2: Open new browser context
const context2 = await browser.newContext()
const page2 = await context2.newPage()
await page2.goto(`/board/${roomId}`)
await waitForCanvas(page2)
// Should see the same shapes (from server sync or IndexedDB)
// Note: This tests server-side persistence, not just IndexedDB
const countSession2 = await getShapeCount(page2)
expect(countSession2).toBe(countSession1)
await context2.close()
})
})
test.describe('Conflict Resolution', () => {
test('handles concurrent offline edits gracefully', async ({ browser }) => {
const roomId = getTestRoomId()
// Create two contexts
const context1 = await browser.newContext()
const context2 = await browser.newContext()
const page1 = await context1.newPage()
const page2 = await context2.newPage()
try {
// Both connect to same room
await Promise.all([
page1.goto(`/board/${roomId}`),
page2.goto(`/board/${roomId}`)
])
await Promise.all([
waitForCanvas(page1),
waitForCanvas(page2)
])
// Wait for initial sync
await page1.waitForTimeout(3000)
await page2.waitForTimeout(3000)
// Both go offline
await context1.setOffline(true)
await context2.setOffline(true)
await page1.waitForTimeout(1000)
// Both make edits while offline
await createShape(page1, 100, 100)
await createShape(page2, 200, 200)
await page1.waitForTimeout(1000)
await page2.waitForTimeout(1000)
// Both come back online
await context1.setOffline(false)
await context2.setOffline(false)
// Wait for sync and conflict resolution
await page1.waitForTimeout(5000)
await page2.waitForTimeout(5000)
// Both should see both shapes (CRDT merge)
const count1 = await getShapeCount(page1)
const count2 = await getShapeCount(page2)
// Both should have merged to same state
expect(count1).toBe(count2)
// Should have at least 2 shapes (both offline edits)
expect(count1).toBeGreaterThanOrEqual(2)
} finally {
await context1.close()
await context2.close()
}
})
})