mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-21 06:41:33 +00:00
Add missed files
This commit is contained in:
294
src/apps/webapp/test/e2e.spec.ts
Normal file
294
src/apps/webapp/test/e2e.spec.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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<string, unknown> {
|
||||
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<Page> {
|
||||
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.<method>(...args)` in the page. */
|
||||
async function call<M extends keyof LiveSyncTestAPI>(
|
||||
page: Page,
|
||||
method: M,
|
||||
...args: Parameters<LiveSyncTestAPI[M]>
|
||||
): Promise<Awaited<ReturnType<LiveSyncTestAPI[M]>>> {
|
||||
const invoke = () =>
|
||||
page.evaluate(([m, a]) => (window as any).livesyncTest[m](...a), [method, args] as [
|
||||
string,
|
||||
unknown[],
|
||||
]) as Promise<Awaited<ReturnType<LiveSyncTestAPI[M]>>>;
|
||||
|
||||
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<void> {
|
||||
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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user