* feat: add conversations, desktop (Tauri), and offline sync Major new features: - conversations module: Slack-like channels, threads, reactions, pins - Tauri desktop app with local SQLite for offline-first operation - Hybrid logical clock sync engine with conflict resolution - DB provider abstraction (D1/Tauri/memory) with React context Conversations: - Text/voice/announcement channels with categories - Message threads, reactions, attachments, pinning - Real-time presence and typing indicators - Full-text search across messages Desktop (Tauri): - Local SQLite database with sync to cloud D1 - Offline mutation queue with automatic replay - Window management and keyboard shortcuts - Desktop shell with offline banner Sync infrastructure: - Vector clock implementation for causality tracking - Last-write-wins with semantic conflict resolution - Delta sync via checkpoints for bandwidth efficiency - Comprehensive test coverage Also adds e2e test setup with Playwright and CI workflows for desktop releases. * fix(tests): sync engine test schema and checkpoint logic - Add missing process_after column and sync_tombstone table to test schemas - Fix checkpoint update to save cursor even when records array is empty - Revert claude-code-review.yml workflow changes to match main --------- Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
222 lines
7.3 KiB
TypeScript
222 lines
7.3 KiB
TypeScript
import { test, expect } from "@playwright/test"
|
|
|
|
// Helper to check if running in Tauri desktop environment
|
|
function isTauri(): boolean {
|
|
return process.env.TAURI === "true" || process.env.TAURI_TEST === "true"
|
|
}
|
|
|
|
// Desktop-only E2E tests
|
|
test.describe("Offline mode", () => {
|
|
test.skip(!isTauri(), "Desktop only")
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto("/")
|
|
// Wait for app to load
|
|
await page.waitForSelector('[data-testid="app-loaded"], body', { timeout: 30000 })
|
|
})
|
|
|
|
test("shows offline banner when network is unavailable", async ({ page }) => {
|
|
// Go offline
|
|
await page.context().setOffline(true)
|
|
|
|
// Try to navigate or perform an action that requires network
|
|
await page.reload({ waitUntil: "networkidle" }).catch(() => {
|
|
// Ignore reload errors when offline
|
|
})
|
|
|
|
// Check for offline indicator
|
|
const offlineBanner = page.locator('[data-testid="offline-banner"]')
|
|
await expect(offlineBanner).toBeVisible({ timeout: 10000 })
|
|
|
|
// Banner should indicate offline status
|
|
await expect(offlineBanner).toContainText(/offline|unavailable|disconnected/i)
|
|
})
|
|
|
|
test("queues mutations when offline", async ({ page }) => {
|
|
// Go offline first
|
|
await page.context().setOffline(true)
|
|
|
|
// Wait for offline state to be detected
|
|
await page.waitForTimeout(1000)
|
|
|
|
// Create a record (this should be queued locally)
|
|
// Assuming there's a form or button to create a record
|
|
const createButton = page.locator('[data-testid="create-record-btn"]')
|
|
if (await createButton.isVisible()) {
|
|
await createButton.click()
|
|
|
|
// Fill out form if present
|
|
const nameInput = page.locator('[data-testid="record-name-input"]')
|
|
if (await nameInput.isVisible()) {
|
|
await nameInput.fill("Offline Test Record")
|
|
await page.locator('[data-testid="save-record-btn"]').click()
|
|
}
|
|
|
|
// Verify queued indicator
|
|
const queuedIndicator = page.locator('[data-testid="sync-status-queued"]')
|
|
await expect(queuedIndicator).toBeVisible({ timeout: 5000 })
|
|
}
|
|
})
|
|
|
|
test("syncs when back online", async ({ page }) => {
|
|
// Create a record while online first
|
|
const initialCount = await page.locator('[data-testid="record-item"]').count()
|
|
|
|
// Go offline
|
|
await page.context().setOffline(true)
|
|
await page.waitForTimeout(1000)
|
|
|
|
// Create a record
|
|
const createButton = page.locator('[data-testid="create-record-btn"]')
|
|
if (await createButton.isVisible()) {
|
|
await createButton.click()
|
|
await page.waitForTimeout(500)
|
|
}
|
|
|
|
// Go back online
|
|
await page.context().setOffline(false)
|
|
|
|
// Wait for sync to complete
|
|
const syncIndicator = page.locator('[data-testid="sync-status-synced"]')
|
|
await expect(syncIndicator).toBeVisible({ timeout: 30000 })
|
|
|
|
// Verify the record count increased
|
|
const newCount = await page.locator('[data-testid="record-item"]').count()
|
|
expect(newCount).toBeGreaterThanOrEqual(initialCount)
|
|
})
|
|
|
|
test("persists data locally across reloads", async ({ page }) => {
|
|
// Create some data
|
|
const testText = `Test ${Date.now()}`
|
|
|
|
// Fill in a form or create a record
|
|
const input = page.locator('[data-testid="note-input"]')
|
|
if (await input.isVisible()) {
|
|
await input.fill(testText)
|
|
await page.locator('[data-testid="save-note-btn"]').click()
|
|
await page.waitForTimeout(1000)
|
|
}
|
|
|
|
// Reload the page
|
|
await page.reload()
|
|
|
|
// Verify the data persists
|
|
const persistedInput = page.locator('[data-testid="note-input"]')
|
|
if (await persistedInput.isVisible()) {
|
|
await expect(persistedInput).toHaveValue(testText)
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe("Sync status indicators", () => {
|
|
test.skip(!isTauri(), "Desktop only")
|
|
|
|
test("shows synced status when online and synced", async ({ page }) => {
|
|
await page.goto("/")
|
|
await page.waitForSelector('[data-testid="app-loaded"], body', { timeout: 30000 })
|
|
|
|
// Check for sync status indicator
|
|
const syncStatus = page.locator('[data-testid="sync-status"]')
|
|
|
|
// Should show as synced or syncing
|
|
const statusText = await syncStatus.textContent()
|
|
expect(statusText).toMatch(/synced|syncing|online/i)
|
|
})
|
|
|
|
test("shows pending count when there are queued items", async ({ page }) => {
|
|
await page.goto("/")
|
|
|
|
// Go offline and create records
|
|
await page.context().setOffline(true)
|
|
await page.waitForTimeout(1000)
|
|
|
|
// Check for pending indicator
|
|
const pendingCount = page.locator('[data-testid="pending-sync-count"]')
|
|
if (await pendingCount.isVisible()) {
|
|
const count = await pendingCount.textContent()
|
|
expect(parseInt(count ?? "0", 10)).toBeGreaterThanOrEqual(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe("Conflict resolution UI", () => {
|
|
test.skip(!isTauri(), "Desktop only")
|
|
|
|
test("shows conflict dialog when conflicts detected", async ({ page }) => {
|
|
// This test would require setting up a specific conflict scenario
|
|
// For now, we check if the conflict resolution UI exists
|
|
await page.goto("/")
|
|
|
|
// Check for conflict dialog or banner
|
|
const conflictBanner = page.locator('[data-testid="conflict-banner"]')
|
|
const conflictDialog = page.locator('[data-testid="conflict-dialog"]')
|
|
|
|
// If visible, verify it has resolution options
|
|
if (await conflictBanner.isVisible()) {
|
|
await expect(conflictBanner).toContainText(/conflict/i)
|
|
}
|
|
|
|
if (await conflictDialog.isVisible()) {
|
|
// Should have options to resolve
|
|
const useLocalBtn = page.locator('[data-testid="use-local-btn"]')
|
|
const useRemoteBtn = page.locator('[data-testid="use-remote-btn"]')
|
|
|
|
await expect(useLocalBtn.or(useRemoteBtn)).toBeVisible()
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe("Database operations", () => {
|
|
test.skip(!isTauri(), "Desktop only")
|
|
|
|
test("performs CRUD operations locally", async ({ page }) => {
|
|
await page.goto("/")
|
|
await page.waitForSelector('[data-testid="app-loaded"], body', { timeout: 30000 })
|
|
|
|
// Create
|
|
const createBtn = page.locator('[data-testid="create-item-btn"]')
|
|
if (await createBtn.isVisible()) {
|
|
await createBtn.click()
|
|
|
|
// Fill form
|
|
const nameInput = page.locator('[data-testid="item-name"]')
|
|
if (await nameInput.isVisible()) {
|
|
await nameInput.fill("Test Item E2E")
|
|
await page.locator('[data-testid="submit-btn"]').click()
|
|
|
|
// Verify created
|
|
await expect(page.locator("text=Test Item E2E")).toBeVisible({ timeout: 5000 })
|
|
}
|
|
|
|
// Update
|
|
const editBtn = page.locator('[data-testid="edit-item-btn"]').first()
|
|
if (await editBtn.isVisible()) {
|
|
await editBtn.click()
|
|
await nameInput.fill("Test Item E2E Updated")
|
|
await page.locator('[data-testid="submit-btn"]').click()
|
|
|
|
await expect(page.locator("text=Test Item E2E Updated")).toBeVisible({
|
|
timeout: 5000,
|
|
})
|
|
}
|
|
|
|
// Delete
|
|
const deleteBtn = page.locator('[data-testid="delete-item-btn"]').first()
|
|
if (await deleteBtn.isVisible()) {
|
|
await deleteBtn.click()
|
|
|
|
// Confirm deletion if dialog appears
|
|
const confirmBtn = page.locator('[data-testid="confirm-delete-btn"]')
|
|
if (await confirmBtn.isVisible()) {
|
|
await confirmBtn.click()
|
|
}
|
|
|
|
// Verify deleted
|
|
await expect(page.locator("text=Test Item E2E Updated")).not.toBeVisible({
|
|
timeout: 5000,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
})
|