/** * WebApp E2E tests – two-vault scenarios. * * Each vault (A and B) runs in its own browser context so that JavaScript * global state (including Trystero's global signalling tables) is fully * isolated. The two vaults communicate only through the shared remote * CouchDB database. * * Vault storage is OPFS-backed – no file-picker interaction needed. * * Prerequisites: * - A reachable CouchDB instance whose connection details are in .test.env * (read automatically by playwright.config.ts). * * How to run: * cd src/apps/webapp && npm run test:e2e */ import { test, expect, type BrowserContext, type Page, type TestInfo } from "@playwright/test"; import type { LiveSyncTestAPI } from "../test-entry"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // --------------------------------------------------------------------------- // Settings helpers // --------------------------------------------------------------------------- function requireEnv(name: string): string { const v = process.env[name]; if (!v) throw new Error(`Missing required env variable: ${name}`); return v; } async function ensureCouchDbDatabase(uri: string, user: string, pass: string, dbName: string): Promise { const base = uri.replace(/\/+$/, ""); const dbUrl = `${base}/${encodeURIComponent(dbName)}`; const auth = Buffer.from(`${user}:${pass}`, "utf-8").toString("base64"); const response = await fetch(dbUrl, { method: "PUT", headers: { Authorization: `Basic ${auth}`, }, }); // 201: created, 202: accepted, 412: already exists if (response.status === 201 || response.status === 202 || response.status === 412) { return; } const body = await response.text().catch(() => ""); throw new Error(`Failed to ensure CouchDB database (${response.status}): ${body}`); } function buildSettings(dbName: string): Record { return { // Remote database (shared between A and B – this is the replication target) couchDB_URI: requireEnv("hostname").replace(/\/+$/, ""), couchDB_USER: process.env["username"] ?? "", couchDB_PASSWORD: process.env["password"] ?? "", couchDB_DBNAME: dbName, // Core behaviour isConfigured: true, liveSync: false, syncOnSave: false, syncOnStart: false, periodicReplication: false, gcDelay: 0, savingDelay: 0, notifyThresholdOfRemoteStorageSize: 0, // Encryption off for test simplicity encrypt: false, // Disable plugin/hidden-file sync (not needed in webapp) usePluginSync: false, autoSweepPlugins: false, autoSweepPluginsPeriodic: false, //Auto accept perr P2P_AutoAcceptingPeers: "~.*", }; } // --------------------------------------------------------------------------- // Test-page helpers // --------------------------------------------------------------------------- /** Navigate to the test entry page and wait for `window.livesyncTest`. */ async function openTestPage(ctx: BrowserContext): Promise { const page = await ctx.newPage(); await page.goto("/test.html"); await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 }); return page; } /** Type-safe wrapper – calls `window.livesyncTest.(...args)` in the page. */ async function call( page: Page, method: M, ...args: Parameters ): Promise>> { const invoke = () => page.evaluate(([m, a]) => (window as any).livesyncTest[m](...a), [method, args] as [ string, unknown[], ]) as Promise>>; try { return await invoke(); } catch (ex: any) { const message = String(ex?.message ?? ex); // Some startup flows may trigger one page reload; recover once. if ( message.includes("Execution context was destroyed") || message.includes("Most likely the page has been closed") ) { await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 }); return await invoke(); } throw ex; } } async function dumpCoverage(page: Page | undefined, label: string, testInfo: TestInfo): Promise { if (!process.env.PW_COVERAGE || !page || page.isClosed()) { return; } const cov = await page .evaluate(() => { const data = (window as any).__coverage__; if (!data) return null; // Reset between tests to avoid runaway accumulation. (window as any).__coverage__ = {}; return data; }) .catch(() => null!); if (!cov) return; if (typeof cov === "object" && Object.keys(cov as Record).length === 0) { return; } const outDir = path.resolve(__dirname, "../.nyc_output"); mkdirSync(outDir, { recursive: true }); const name = `${testInfo.testId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${label}.json`; writeFileSync(path.join(outDir, name), JSON.stringify(cov), "utf-8"); } // --------------------------------------------------------------------------- // Two-vault E2E suite // --------------------------------------------------------------------------- test.describe("WebApp two-vault E2E", () => { let ctxA: BrowserContext; let ctxB: BrowserContext; let pageA: Page; let pageB: Page; const DB_SUFFIX = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const dbName = `${requireEnv("dbname")}-${DB_SUFFIX}`; const settings = buildSettings(dbName); test.beforeAll(async ({ browser }) => { await ensureCouchDbDatabase( String(settings.couchDB_URI ?? ""), String(settings.couchDB_USER ?? ""), String(settings.couchDB_PASSWORD ?? ""), dbName ); // Open Vault A and Vault B in completely separate browser contexts. // Each context has its own JS runtime, IndexedDB and OPFS root, so // Trystero global state and PouchDB instance names cannot collide. ctxA = await browser.newContext(); ctxB = await browser.newContext(); pageA = await openTestPage(ctxA); pageB = await openTestPage(ctxB); await call(pageA, "init", "testvault_a", settings as any); await call(pageB, "init", "testvault_b", settings as any); }); test.afterAll(async () => { await call(pageA, "shutdown").catch(() => {}); await call(pageB, "shutdown").catch(() => {}); await ctxA.close(); await ctxB.close(); }); test.afterEach(async ({}, testInfo) => { await dumpCoverage(pageA, "vaultA", testInfo); await dumpCoverage(pageB, "vaultB", testInfo); }); // ----------------------------------------------------------------------- // Case 1: Vault A writes a file and can read its metadata back from the // local database (no replication yet). // ----------------------------------------------------------------------- test("Case 1: A writes a file and can get its info", async () => { const FILE = "e2e/case1-a-only.md"; const CONTENT = "hello from vault A"; const ok = await call(pageA, "putFile", FILE, CONTENT); expect(ok).toBe(true); const info = await call(pageA, "getInfo", FILE); expect(info).not.toBeNull(); expect(info!.path).toBe(FILE); expect(info!.revision).toBeTruthy(); expect(info!.conflicts).toHaveLength(0); }); // ----------------------------------------------------------------------- // Case 2: Vault A writes a file, both vaults replicate, and Vault B ends // up with the file in its local database. // ----------------------------------------------------------------------- test("Case 2: A writes a file, both replicate, B receives the file", async () => { const FILE = "e2e/case2-sync.md"; const CONTENT = "content from A – should appear in B"; await call(pageA, "putFile", FILE, CONTENT); // A pushes to remote, B pulls from remote. await call(pageA, "replicate"); await call(pageB, "replicate"); const infoB = await call(pageB, "getInfo", FILE); expect(infoB).not.toBeNull(); expect(infoB!.path).toBe(FILE); }); // ----------------------------------------------------------------------- // Case 3: Vault A deletes the file it synced in case 2. After both // vaults replicate, Vault B no longer sees the file. // ----------------------------------------------------------------------- test("Case 3: A deletes the file, both replicate, B no longer sees it", async () => { // This test depends on Case 2 having put e2e/case2-sync.md into both vaults. const FILE = "e2e/case2-sync.md"; await call(pageA, "deleteFile", FILE); await call(pageA, "replicate"); await call(pageB, "replicate"); const infoB = await call(pageB, "getInfo", FILE); // The file should be gone (null means not found or deleted). expect(infoB).toBeNull(); }); // ----------------------------------------------------------------------- // Case 4: A and B each independently edit the same file that was already // synced. After both vaults replicate the editing cycle, both // vaults report a conflict on that file. // ----------------------------------------------------------------------- test("Case 4: concurrent edits from A and B produce a conflict on both sides", async () => { const FILE = "e2e/case4-conflict.md"; // 1) Write a baseline and synchronise so both vaults start from the // same revision. await call(pageA, "putFile", FILE, "base content"); await call(pageA, "replicate"); await call(pageB, "replicate"); // Confirm B has the base file with no conflicts yet. const baseInfoB = await call(pageB, "getInfo", FILE); expect(baseInfoB).not.toBeNull(); expect(baseInfoB!.conflicts).toHaveLength(0); // 2) Both vaults write diverging content without syncing in between – // this creates two competing revisions. await call(pageA, "putFile", FILE, "content from A (conflict side)"); await call(pageB, "putFile", FILE, "content from B (conflict side)"); // 3) Run replication on both sides. The order mirrors the pattern // from the CLI two-vault tests (A → remote → B → remote → A). await call(pageA, "replicate"); await call(pageB, "replicate"); await call(pageA, "replicate"); // re-check from A to pick up B's revision // 4) At least one side must report a conflict. const hasConflictA = await call(pageA, "hasConflict", FILE); const hasConflictB = await call(pageB, "hasConflict", FILE); expect( hasConflictA || hasConflictB, "Expected a conflict to appear on vault A or vault B after diverging edits" ).toBe(true); }); });