refactor: compose browser application runtimes

This commit is contained in:
vorotamoroz
2026-07-29 09:11:57 +00:00
parent b22c1bd777
commit 4723771ee0
65 changed files with 3919 additions and 2771 deletions
@@ -0,0 +1,88 @@
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import type {
ComponentHasResult,
DialogHostProps,
SvelteDialogManagerDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { Component } from "svelte";
import { describe, expect, it, vi } from "vitest";
import {
SvelteDialogSession,
type SvelteDialogRenderer,
type SvelteDialogSurface,
} from "@/modules/services/SvelteDialogSession";
describe("SvelteDialogSession", () => {
it("mounts, resolves the selected result, and unmounts through the owning surface", async () => {
const context = createServiceContext();
const replaceChildren = vi.fn();
const setTitle = vi.fn();
const close = vi.fn();
const mounted = {};
const unmount = vi.fn().mockResolvedValue(undefined);
let mountedProps: DialogHostProps<string, string> | undefined;
const renderer: SvelteDialogRenderer = {
mount(_dialogHost, _target, props) {
mountedProps = props as DialogHostProps<string, string>;
return mounted as never;
},
unmount,
};
const session = new SvelteDialogSession({
surface: {
contentEl: { replaceChildren } as unknown as HTMLElement,
close,
setTitle,
},
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
initialData: "initial",
renderer,
});
session.onOpen();
const result = session.waitForClose();
mountedProps?.setTitle("Dialogue title");
mountedProps?.setResult("selected");
session.onClose();
await expect(result).resolves.toBe("selected");
expect(replaceChildren).toHaveBeenCalledOnce();
expect(setTitle).toHaveBeenCalledWith("Dialogue title");
const getInitialData = mountedProps?.getInitialData;
expect(getInitialData).toBeTypeOf("function");
expect(getInitialData?.()).toBe("initial");
await vi.waitFor(() => expect(unmount).toHaveBeenCalledWith(mounted));
});
it("rejects the pending result and closes the surface when the owning context unloads", async () => {
const context = createServiceContext();
const close = vi.fn();
const session = new SvelteDialogSession({
surface: {
contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement,
close,
setTitle: vi.fn(),
},
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
renderer: {
mount: () => ({}) as never,
unmount: vi.fn().mockResolvedValue(undefined),
},
});
session.onOpen();
const result = session.waitForClose();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
await expect(result).rejects.toThrow("Plug-in unloaded");
expect(close).toHaveBeenCalledOnce();
});
});
+242
View File
@@ -0,0 +1,242 @@
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
const runtimeMocks = vi.hoisted(() => {
const addLog = vi.fn();
const cleanup = vi.fn();
const currentSettings = vi.fn();
const getFiles = vi.fn();
const onLoad = vi.fn();
const onReady = vi.fn();
const onUnload = vi.fn();
const scanVault = vi.fn();
const scanDirectory = vi.fn();
const clearCache = vi.fn();
const collectFilesOnStorage = vi.fn();
const updateToDatabase = vi.fn();
const p2p = {
replicator: {},
};
const serviceHub = {
API: { addLog },
control: { onLoad, onReady, onUnload },
setting: { currentSettings },
vault: { scanVault },
};
const platformModules = {
fileHandler: {},
storageAccess: {},
storageEventManager: { cleanup },
vaultAccess: {
fsapiAdapter: {
clearCache,
getFiles,
scanDirectory,
},
},
};
return {
addLog,
options: undefined as LiveSyncBrowserServiceHubOptions<never> | undefined,
clearCache,
cleanup,
collectFilesOnStorage,
currentSettings,
getFiles,
onLoad,
onReady,
onUnload,
platformModules,
p2p,
scanDirectory,
scanVault,
serviceHub,
updateToDatabase,
};
});
vi.mock("@/apps/browser/createLiveSyncBrowserServiceHub", () => ({
createLiveSyncBrowserServiceHub: vi.fn((options: LiveSyncBrowserServiceHubOptions<never>) => {
runtimeMocks.options = options;
return runtimeMocks.serviceHub;
}),
}));
vi.mock("@/apps/webapp/serviceModules/FSAPIServiceModules", () => ({
initialiseServiceModulesFSAPI: vi.fn(() => runtimeMocks.platformModules),
}));
vi.mock("@/LiveSyncBaseCore", () => ({
LiveSyncBaseCore: class {
readonly serviceModules;
readonly services;
constructor(
services: typeof runtimeMocks.serviceHub,
initialisePlatformModules: (core: unknown, serviceHub: typeof runtimeMocks.serviceHub) => unknown,
_initialiseCoreModules: () => unknown[],
_getAddOns: () => unknown[],
initialiseFeatures: (core: unknown) => void
) {
this.services = services;
this.serviceModules = initialisePlatformModules(this, services);
initialiseFeatures(this);
}
},
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", () => ({
collectFilesOnStorage: runtimeMocks.collectFilesOnStorage,
updateToDatabase: runtimeMocks.updateToDatabase,
useOfflineScanner: vi.fn(),
}));
vi.mock("@/serviceFeatures/redFlag", () => ({
useRedFlagFeatures: vi.fn(),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize", () => ({
useCheckRemoteSize: vi.fn(),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => ({
useRemoteConfiguration: vi.fn(),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature", () => ({
useP2PReplicatorFeature: vi.fn(() => runtimeMocks.p2p),
}));
import { WebAppRuntime } from "@/apps/webapp/WebAppRuntime";
const unconfiguredSettings = {
couchDB_DBNAME: "",
isConfigured: false,
} as ObsidianLiveSyncSettings;
function createRootHandle(name = "runtime-vault"): FileSystemDirectoryHandle {
return { name } as FileSystemDirectoryHandle;
}
async function waitForMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("WebAppRuntime lifecycle", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeMocks.options = undefined;
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
runtimeMocks.getFiles.mockResolvedValue([{ path: "one.md" }]);
runtimeMocks.onLoad.mockResolvedValue(true);
runtimeMocks.onReady.mockResolvedValue(undefined);
runtimeMocks.onUnload.mockResolvedValue(undefined);
runtimeMocks.cleanup.mockResolvedValue(undefined);
runtimeMocks.clearCache.mockReturnValue(undefined);
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
storageFileNameMap: {
"one.md": {
path: "one.md",
stat: { ctime: 1, mtime: 1, size: 3, type: "file" },
},
},
storageFileNames: ["one.md"],
storageFileNameCI2CS: { "one.md": "one.md" },
});
runtimeMocks.scanDirectory.mockResolvedValue(undefined);
runtimeMocks.scanVault.mockResolvedValue(false);
runtimeMocks.updateToDatabase.mockResolvedValue(undefined);
});
it("owns core start, directory scan, status reporting, and shutdown", async () => {
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
await runtime.start();
expect(runtimeMocks.onLoad).toHaveBeenCalledOnce();
expect(runtimeMocks.onReady).toHaveBeenCalledOnce();
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
expect(runtimeMocks.getFiles).toHaveBeenCalledOnce();
expect(runtimeMocks.addLog).toHaveBeenCalledWith("Found 1 files", expect.anything(), "scan");
expect(reportStatus).toHaveBeenCalledWith(
"warning",
"Warning: Please configure CouchDB connection in settings"
);
expect(runtime.p2pPaneHost.services).toBe(runtimeMocks.serviceHub);
expect(runtime.p2pPaneHost.p2p).toBe(runtimeMocks.p2p);
expect(runtime.p2pPaneHost.showPeerMenu).toBeUndefined();
expect("p2pController" in runtime).toBe(false);
await runtime.shutdown();
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
});
it("imports local files for optional P2P while the main remote remains unconfigured", async () => {
const runtime = new WebAppRuntime(createRootHandle());
await runtime.start();
vi.clearAllMocks();
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
storageFileNameMap: {
"one.md": {
path: "one.md",
stat: { ctime: 1, mtime: 1, size: 3, type: "file" },
},
},
storageFileNames: ["one.md"],
storageFileNameCI2CS: { "one.md": "one.md" },
});
await expect(runtime.scanLocalFiles()).resolves.toBe(true);
expect(runtimeMocks.clearCache).toHaveBeenCalledOnce();
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
expect(runtimeMocks.collectFilesOnStorage).toHaveBeenCalledWith(
expect.objectContaining({
services: runtimeMocks.serviceHub,
serviceModules: runtimeMocks.platformModules,
}),
unconfiguredSettings,
expect.any(Function)
);
expect(runtimeMocks.updateToDatabase).toHaveBeenCalledOnce();
expect(runtimeMocks.scanVault).not.toHaveBeenCalled();
expect(runtimeMocks.currentSettings()).toBe(unconfiguredSettings);
expect(unconfiguredSettings.isConfigured).toBe(false);
});
it("rejects a failed core start after cleaning up the partial runtime", async () => {
runtimeMocks.onLoad.mockResolvedValue(false);
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
expect(reportStatus).toHaveBeenCalledWith(
"error",
"Error: Failed to start: Error: Failed to initialise LiveSync"
);
});
it("shuts down once before scheduling a host reload", async () => {
const scheduleReload = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload });
await runtime.start();
runtimeMocks.options?.restart?.schedule();
runtimeMocks.options?.restart?.schedule();
await waitForMicrotasks();
expect(runtimeMocks.options?.restart?.isScheduled?.()).toBe(true);
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
expect(scheduleReload).toHaveBeenCalledOnce();
expect(scheduleReload).toHaveBeenCalledWith(1_000);
expect(runtimeMocks.addLog).toHaveBeenCalledWith("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
});
});
@@ -0,0 +1,58 @@
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { describe, expect, it } from "vitest";
import {
WEBPEER_SETTINGS_KEY,
createWebPeerPersistence,
} from "@/apps/webpeer/src/WebPeerPersistence";
function createMemoryStore(initial: Record<string, unknown> = {}): SimpleStore<unknown> {
const values = new Map(Object.entries(initial));
return {
db: Promise.resolve(undefined),
get: async (key) => values.get(key),
set: async (key, value) => {
values.set(key, value);
},
delete: async (key) => {
values.delete(key);
},
keys: async (from, to, count) => {
const selected = [...values.keys()]
.sort()
.filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to));
return count === undefined ? selected : selected.slice(0, count);
},
};
}
describe("WebPeer settings persistence", () => {
it("loads P2P-only defaults and saves the complete settings object", async () => {
const store = createMemoryStore({
[WEBPEER_SETTINGS_KEY]: {
P2P_AppID: "custom-app",
P2P_roomID: "room-42",
P2P_Enabled: true,
},
});
const persistence = createWebPeerPersistence(store);
const loaded = await persistence.settings.load();
expect(loaded).toEqual(
expect.objectContaining({
P2P_AppID: "custom-app",
P2P_roomID: "room-42",
P2P_Enabled: true,
remoteType: REMOTE_P2P,
isConfigured: true,
additionalSuffixOfDatabaseName: "",
suspendParseReplicationResult: true,
})
);
const updated = { ...loaded!, P2P_AutoStart: true };
await persistence.settings.save(updated);
await expect(store.get(WEBPEER_SETTINGS_KEY)).resolves.toEqual(updated);
});
});
@@ -0,0 +1,76 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { describe, expect, it, vi } from "vitest";
import { WebPeerRuntime } from "@/apps/webpeer/src/WebPeerRuntime";
function createMemoryStore(): SimpleStore<unknown> {
const values = new Map<string, unknown>();
return {
db: Promise.resolve(undefined),
get: async (key) => values.get(key),
set: async (key, value) => {
values.set(key, value);
},
delete: async (key) => {
values.delete(key);
},
keys: async () => [...values.keys()],
};
}
describe("WebPeer runtime composition", () => {
it("preserves one context and live P2P result across the service graph and pane host", () => {
const context = createServiceContext({
translate: (key) => `webpeer:${key}`,
});
const runtime = new WebPeerRuntime({
context,
store: createMemoryStore(),
});
expect(runtime.context).toBe(context);
expect(runtime.services.context).toBe(context);
expect(runtime.events).toBe(context.events);
expect(runtime.currentReplicator).toBe(runtime.p2p.replicator);
expect(runtime.paneHost.services).toBe(runtime.services);
expect(runtime.paneHost.p2p).toBe(runtime.p2p);
expect(runtime.paneHost.showPeerMenu).toBeTypeOf("function");
expect("controller" in runtime).toBe(false);
});
it("loads settings and opens the local database before announcing layout readiness", async () => {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
});
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({
...DEFAULT_SETTINGS,
P2P_AutoStart: false,
P2P_Enabled: false,
});
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
const markIsReady = vi.spyOn(runtime.services.appLifecycle, "markIsReady");
const layoutReady = vi.fn();
runtime.events.onEvent(EVENT_LAYOUT_READY, layoutReady);
await expect(runtime.start()).resolves.toBe(runtime);
expect(loadSettings).toHaveBeenCalledOnce();
expect(openDatabase).toHaveBeenCalledOnce();
expect(markIsReady).toHaveBeenCalledOnce();
expect(layoutReady).toHaveBeenCalledOnce();
});
it("rejects start when the browser-local database cannot be opened", async () => {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
});
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
});
});
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
FROM denoland/deno:bin-2.9.2 AS deno
FROM mcr.microsoft.com/playwright:v1.62.0-noble
COPY --from=deno /deno /usr/local/bin/deno
ENV DENO_DIR=/deno-dir
WORKDIR /opt/livesync-browser-apps-interop
COPY test/browser-apps/deno.json test/browser-apps/deno.lock ./
COPY test/browser-apps/helpers ./helpers
COPY test/browser-apps/interop.test.ts ./
RUN deno cache --frozen --config deno.json --lock deno.lock interop.test.ts
WORKDIR /workspace
CMD ["deno", "test", "-A", "--no-check", "--frozen", "--config", "test/browser-apps/deno.json", "--lock", "test/browser-apps/deno.lock", "test/browser-apps/interop.test.ts"]
+26
View File
@@ -0,0 +1,26 @@
# Browser application tests
Browser application tests use Deno and headless Chromium. They do not depend on Obsidian or the retired browser Harness.
Application unit tests are stored in `test/apps/webapp/` and `test/apps/webpeer/`. Both the unit and browser tests remain outside `src`, because test-file ignores do not apply to the Community Review source boundary.
Each application has its own production-bundle smoke-test directory outside `src`:
- `test/browser-apps/webapp/` covers Vault selection, OPFS start-up, and isolation between optional P2P settings and the main remote.
- `test/browser-apps/webpeer/` covers start-up, settings persistence, and reload.
Run both app-owned tests with:
```bash
npm run test:browser-apps
```
`test/browser-apps/interop.test.ts` is the only cross-application scenario. It configures the real WebApp and WebPeer interfaces, transfers a file from WebApp to WebPeer, then starts the built CLI as the final peer and verifies the file through the production CLI. The CLI is a test fixture in this scenario; the PoC test and its support code are not owned by the CLI package.
Run the isolated relay and TURN scenario in Compose with:
```bash
npm run test:e2e:browser-apps:interop
```
The Deno dependency lock is shared only by these browser application tests.
+65
View File
@@ -0,0 +1,65 @@
name: livesync-browser-apps-interop
services:
nostr-relay:
image: ghcr.io/hoytech/strfry:latest
entrypoint: sh
command:
- -lc
- |
cat > /tmp/strfry.conf <<'EOF'
db = "./strfry-db/"
relay {
bind = "0.0.0.0"
port = 7777
nofiles = 65536
info {
name = "livesync browser application test relay"
description = "local relay for the browser application interoperability test"
}
maxWebsocketPayloadSize = 131072
autoPingSeconds = 55
writePolicy {
plugin = ""
}
}
EOF
exec /app/strfry --config /tmp/strfry.conf relay
tmpfs:
- /app/strfry-db:rw,size=256m,mode=1777
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"]
interval: 2s
timeout: 5s
retries: 30
coturn:
image: coturn/coturn:latest
command:
- --log-file=stdout
- --listening-port=3478
- --user=testuser:testpass
- --realm=livesync.test
browser-apps-interop:
build:
context: ../..
dockerfile: test/browser-apps/Dockerfile.interop
depends_on:
nostr-relay:
condition: service_healthy
coturn:
condition: service_started
environment:
BROWSER_APPS_P2P_RELAY_URL: ws://nostr-relay:7777/
BROWSER_APPS_P2P_TURN_SERVERS: turn:coturn:3478
BROWSER_APPS_P2P_TURN_USERNAME: testuser
BROWSER_APPS_P2P_TURN_CREDENTIAL: testpass
init: true
ipc: host
volumes:
- ../..:/workspace
+7
View File
@@ -0,0 +1,7 @@
{
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.13",
"@std/path": "jsr:@std/path@^1.0.9",
"playwright": "npm:playwright@1.62.0"
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.13": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/internal@^1.0.14": "1.0.14",
"jsr:@std/path@^1.0.9": "1.1.6",
"npm:playwright@1.62.0": "1.62.0"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal@^1.0.12"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/path@1.1.6": {
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
"dependencies": [
"jsr:@std/internal@^1.0.14"
]
}
},
"npm": {
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
"scripts": true
},
"playwright-core@1.62.0": {
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"bin": true
},
"playwright@1.62.0": {
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dependencies": [
"playwright-core"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@^1.0.13",
"jsr:@std/path@^1.0.9",
"npm:playwright@1.62.0"
]
}
}
+103
View File
@@ -0,0 +1,103 @@
import { assertEquals } from "@std/assert";
import { extname, relative, resolve } from "@std/path";
import type { Page } from "playwright";
function contentType(path: string): string {
switch (extname(path)) {
case ".css":
return "text/css; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".json":
case ".map":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
function resolveStaticPath(root: string, pathname: string): string | undefined {
const requested = decodeURIComponent(pathname).replace(/^\/+/, "") || "index.html";
const candidate = resolve(root, requested);
const relativePath = relative(root, candidate);
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
return undefined;
}
return candidate;
}
export async function startStaticServer(root: string): Promise<{
readonly baseUrl: string;
close(): Promise<void>;
}> {
let reportListening!: (port: number) => void;
const listening = new Promise<number>((resolvePromise) => {
reportListening = resolvePromise;
});
const server = Deno.serve(
{
hostname: "127.0.0.1",
onListen: ({ port }) => reportListening(port),
port: 0,
},
async (request) => {
const path = resolveStaticPath(root, new URL(request.url).pathname);
if (path === undefined) {
return new Response("Not found", { status: 404 });
}
try {
const stat = await Deno.stat(path);
const filePath = stat.isDirectory ? resolve(path, "index.html") : path;
const file = await Deno.open(filePath, { read: true });
return new Response(file.readable, {
headers: {
"content-type": contentType(filePath),
},
});
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return new Response("Not found", { status: 404 });
}
throw error;
}
}
);
const port = await listening;
return {
baseUrl: `http://127.0.0.1:${port}/`,
close: async () => {
await server.shutdown();
},
};
}
export function observePageFailures(page: Page): () => void {
const failures: string[] = [];
page.on("pageerror", (error) => {
failures.push(`pageerror: ${error.stack ?? error.message}`);
});
page.on("console", (message) => {
if (message.type() === "error") {
failures.push(`console.error: ${message.text()}`);
}
});
return () => assertEquals(failures, [], failures.join("\n"));
}
export async function waitFor(
predicate: () => Promise<boolean>,
message: string,
timeoutMilliseconds = 5_000
): Promise<void> {
const deadline = Date.now() + timeoutMilliseconds;
while (!(await predicate())) {
if (Date.now() >= deadline) {
throw new Error(message);
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
}
}
+172
View File
@@ -0,0 +1,172 @@
import { resolve } from "@std/path";
const repositoryRoot = resolve(import.meta.dirname!, "../../..");
const cliDirectory = resolve(repositoryRoot, "src/apps/cli");
const cliDist = resolve(cliDirectory, "dist/index.cjs");
export interface CliResult {
readonly code: number;
readonly combined: string;
readonly stderr: string;
readonly stdout: string;
}
export class TemporaryDirectory implements AsyncDisposable {
private constructor(readonly path: string) {}
static async create(prefix: string): Promise<TemporaryDirectory> {
return new TemporaryDirectory(await Deno.makeTempDir({ prefix: `${prefix}.` }));
}
resolve(...parts: string[]): string {
return resolve(this.path, ...parts);
}
async [Symbol.asyncDispose](): Promise<void> {
await Deno.remove(this.path, { recursive: true }).catch(() => {});
}
}
async function collectStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
append(decoder.decode());
return;
}
if (value !== undefined) {
append(decoder.decode(value, { stream: true }));
}
}
} finally {
reader.releaseLock();
}
}
export async function runCli(...args: string[]): Promise<CliResult> {
const output = await new Deno.Command("node", {
args: [cliDist, ...args],
cwd: cliDirectory,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).output();
const decoder = new TextDecoder();
const stdout = decoder.decode(output.stdout);
const stderr = decoder.decode(output.stderr);
return {
code: output.code,
combined: stdout + stderr,
stderr,
stdout,
};
}
export async function initSettingsFile(path: string): Promise<void> {
const result = await runCli("init-settings", "--force", path);
if (result.code !== 0) {
throw new Error(`CLI settings initialisation failed with code ${result.code}\n${result.combined}`);
}
}
export function sanitiseCatStdout(raw: string): string {
return raw
.split("\n")
.filter((line) => line !== "[CLIWatchAdapter] File watching is not enabled in CLI version")
.join("\n");
}
export class BackgroundCliProcess {
#stdout = "";
#stderr = "";
readonly #stdoutDone: Promise<void>;
readonly #stderrDone: Promise<void>;
constructor(readonly child: Deno.ChildProcess) {
this.#stdoutDone = collectStream(child.stdout, (text) => {
this.#stdout += text;
});
this.#stderrDone = collectStream(child.stderr, (text) => {
this.#stderr += text;
});
}
get combined(): string {
return this.#stdout + this.#stderr;
}
async stop(): Promise<number> {
try {
this.child.kill("SIGTERM");
} catch {
// The process has already exited.
}
const status = await this.child.status;
await Promise.all([this.#stdoutDone, this.#stderrDone]);
return status.code;
}
}
export function startCliInBackground(...args: string[]): BackgroundCliProcess {
return new BackgroundCliProcess(
new Deno.Command("node", {
args: [cliDist, ...args],
cwd: cliDirectory,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn()
);
}
type WaitForPortOptions = {
timeoutMs?: number;
intervalMs?: number;
connectTimeoutMs?: number;
};
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const connection = await Promise.race([
Deno.connect({ hostname, port }),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(`Connection timed out after ${timeoutMs} ms`)), timeoutMs);
}),
]);
connection.close();
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
export async function waitForPort(
hostname: string,
port: number,
options: WaitForPortOptions = {}
): Promise<void> {
const timeoutMs = options.timeoutMs ?? 15_000;
const intervalMs = options.intervalMs ?? 250;
const connectTimeoutMs = options.connectTimeoutMs ?? 1_000;
const started = Date.now();
let lastError: unknown;
while (Date.now() - started < timeoutMs) {
try {
await connectWithTimeout(hostname, port, connectTimeoutMs);
return;
} catch (error) {
lastError = error;
await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
}
}
throw new Error(
`Port ${hostname}:${port} did not become ready within ${timeoutMs} ms` +
(lastError === undefined ? "" : ` (last error: ${String(lastError)})`)
);
}
+609
View File
@@ -0,0 +1,609 @@
import { assertEquals, assertStringIncludes } from "@std/assert";
import { extname, relative, resolve } from "@std/path";
import { chromium, type Locator, type Page } from "playwright";
import {
type BackgroundCliProcess,
initSettingsFile,
runCli,
sanitiseCatStdout,
startCliInBackground,
TemporaryDirectory,
waitForPort,
} from "./helpers/cli-fixture.ts";
const repositoryRoot = resolve(import.meta.dirname!, "../..");
const webAppDist = resolve(repositoryRoot, "src/apps/webapp/dist");
const webPeerDist = resolve(repositoryRoot, "src/apps/webpeer/dist");
function contentType(path: string): string {
switch (extname(path)) {
case ".css":
return "text/css; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".map":
return "application/json; charset=utf-8";
default:
return "application/octet-stream";
}
}
function resolveStaticPath(pathname: string): string | undefined {
const decodedPath = decodeURIComponent(pathname);
const routes: Array<[string, string]> = [
["/webapp/", webAppDist],
["/webpeer/", webPeerDist],
];
for (const [prefix, root] of routes) {
if (!decodedPath.startsWith(prefix)) {
continue;
}
const requested = decodedPath.slice(prefix.length) || "index.html";
const candidate = resolve(root, requested);
const relativePath = relative(root, candidate);
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
return undefined;
}
return candidate;
}
return undefined;
}
async function serveBrowserApplications(request: Request): Promise<Response> {
const filePath = resolveStaticPath(new URL(request.url).pathname);
if (!filePath) {
return new Response("Not found", { status: 404 });
}
try {
const stat = await Deno.stat(filePath);
const resolvedFile = stat.isDirectory
? resolve(filePath, "index.html")
: filePath;
const file = await Deno.open(resolvedFile, { read: true });
return new Response(file.readable, {
headers: {
"content-type": contentType(resolvedFile),
},
});
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return new Response("Not found", { status: 404 });
}
throw error;
}
}
function observePageFailures(page: Page): () => void {
const failures: string[] = [];
page.on("pageerror", (error) => {
failures.push(`pageerror: ${error.stack ?? error.message}`);
});
page.on("console", (message) => {
if (message.type() === "error") {
failures.push(`console.error: ${message.text()}`);
}
});
return () => assertEquals(failures, [], failures.join("\n"));
}
function formatError(error: unknown): string {
return error instanceof Error
? (error.stack ?? error.message)
: String(error);
}
async function captureBrowserPage(
page: Page,
screenshotDirectory: string | undefined,
filename: string,
fullPage = true,
): Promise<void> {
if (screenshotDirectory === undefined) {
return;
}
const screenshotPath = resolve(screenshotDirectory, filename);
await page.screenshot({
animations: "disabled",
caret: "hide",
fullPage,
path: screenshotPath,
});
console.log(`[Browser applications E2E] Captured ${screenshotPath}`);
}
async function webPeerLogs(page: Page): Promise<string> {
return await page
.locator(".logslist")
.innerText()
.catch(() => "<WebPeer logs unavailable>");
}
async function waitForWebPeerLog(
page: Page,
message: string,
timeoutMilliseconds: number,
): Promise<void> {
try {
await page.locator(".logslist").filter({ hasText: message }).waitFor({
timeout: timeoutMilliseconds,
});
} catch (error) {
throw new Error(
`${formatError(error)}\n\nWebPeer logs:\n${await webPeerLogs(page)}`,
);
}
}
async function waitFor(
predicate: () => Promise<boolean>,
message: string,
timeoutMilliseconds = 5_000,
): Promise<void> {
const deadline = Date.now() + timeoutMilliseconds;
while (!(await predicate())) {
if (Date.now() >= deadline) {
throw new Error(message);
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
}
}
async function configureP2PPane(
root: Locator,
settings: {
deviceName: string;
passphrase: string;
relay: string;
room: string;
turnCredential: string;
turnServers: string;
turnUsername: string;
},
): Promise<void> {
const pane = root.locator("article");
const transportSettings = root.locator(".browser-p2p-transport-settings");
await pane.locator("input[type='checkbox']").first().check();
await pane.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx").fill(
settings.relay,
);
await pane.getByPlaceholder("anything-you-like").fill(settings.room);
await pane.getByPlaceholder("password").fill(settings.passphrase);
await pane.getByPlaceholder("iphone-16").fill(settings.deviceName);
if (settings.turnServers !== "") {
await transportSettings.getByText("Optional TURN server settings", {
exact: true,
}).click();
await transportSettings.getByPlaceholder("turn:turn.example.com:3478").fill(
settings.turnServers,
);
await transportSettings.getByPlaceholder("Enter TURN username").fill(
settings.turnUsername,
);
await transportSettings.getByPlaceholder("Enter TURN credential").fill(
settings.turnCredential,
);
const saveTurn = transportSettings.getByRole("button", {
name: "Save TURN settings",
exact: true,
});
await saveTurn.click();
await waitFor(
async () => await saveTurn.isDisabled(),
`${settings.deviceName} did not apply its TURN settings`,
);
}
const save = pane.getByRole("button", {
name: "Save and Apply",
exact: true,
});
await save.click();
await waitFor(
async () => await save.isDisabled(),
`${settings.deviceName} did not apply its P2P settings`,
);
}
async function configureCliSettings(
settingsPath: string,
settings: {
deviceName: string;
passphrase: string;
relay: string;
room: string;
turnCredential: string;
turnServers: string;
turnUsername: string;
},
): Promise<void> {
await initSettingsFile(settingsPath);
const current = JSON.parse(await Deno.readTextFile(settingsPath)) as Record<
string,
unknown
>;
Object.assign(current, {
P2P_AppID: "self-hosted-livesync",
P2P_AutoAcceptingPeers: "~.*",
P2P_AutoBroadcast: false,
P2P_AutoDenyingPeers: "",
P2P_AutoStart: false,
P2P_DevicePeerName: settings.deviceName,
P2P_Enabled: true,
P2P_IsHeadless: true,
P2P_passphrase: settings.passphrase,
P2P_relays: settings.relay,
P2P_roomID: settings.room,
P2P_turnCredential: settings.turnCredential,
P2P_turnServers: settings.turnServers,
P2P_turnUsername: settings.turnUsername,
encrypt: false,
isConfigured: true,
passphrase: "",
remoteType: "ONLY_P2P",
usePathObfuscation: false,
});
await Deno.writeTextFile(settingsPath, JSON.stringify(current, null, 2));
}
async function waitForBackgroundExit(
process: BackgroundCliProcess,
timeoutMilliseconds: number,
): Promise<number> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const status = await Promise.race([
process.child.status,
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`CLI process timed out\n${process.combined}`)),
timeoutMilliseconds,
);
}),
]);
await process.stop();
return status.code;
} catch (error) {
await process.stop();
throw error;
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
Deno.test({
name: "browser applications: WebApp to WebPeer to CLI",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const relay = Deno.env.get("BROWSER_APPS_P2P_RELAY_URL") ??
"ws://127.0.0.1:4000/";
const turnServers = Deno.env.get("BROWSER_APPS_P2P_TURN_SERVERS") ?? "";
const turnUsername = Deno.env.get("BROWSER_APPS_P2P_TURN_USERNAME") ?? "";
const turnCredential = Deno.env.get("BROWSER_APPS_P2P_TURN_CREDENTIAL") ??
"";
const port = Number(Deno.env.get("BROWSER_APPS_INTEROP_PORT") ?? "43920");
const screenshotDirectory =
Deno.env.get("BROWSER_APPS_SCREENSHOT_DIR")?.trim() || undefined;
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const room = `browser-interop-${nonce}`;
const p2pPassphrase = `browser-interop-passphrase-${nonce}`;
const webAppDeviceName = `webapp-${nonce}`;
const webPeerDeviceName = `webpeer-${nonce}`;
const cliDeviceName = `cli-${nonce}`;
const rootName = `browser-interop-root-${nonce}`;
const notePath = "webapp-to-cli.md";
const noteContent =
`# Browser interoperability\n\nTransferred through WebApp, WebPeer, and CLI.\n${nonce}\n`;
if (screenshotDirectory !== undefined) {
await Deno.mkdir(screenshotDirectory, { recursive: true });
}
await using workDir = await TemporaryDirectory.create(
"livesync-browser-apps-interop",
);
const cliDatabasePath = workDir.resolve("cli-database");
const cliSettingsPath = workDir.resolve("cli-settings.json");
await Deno.mkdir(cliDatabasePath, { recursive: true });
await configureCliSettings(cliSettingsPath, {
deviceName: cliDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
});
const relayUrl = new URL(relay);
await waitForPort(
relayUrl.hostname,
Number(relayUrl.port || (relayUrl.protocol === "wss:" ? 443 : 80)),
{
timeoutMs: 30_000,
},
);
if (turnServers.startsWith("turn:")) {
const [hostnameAndPort] = turnServers.slice("turn:".length).split("?");
const separatorIndex = hostnameAndPort.lastIndexOf(":");
const hostname = hostnameAndPort.slice(0, separatorIndex);
const turnPort = Number(hostnameAndPort.slice(separatorIndex + 1));
if (hostname === "" || !Number.isFinite(turnPort)) {
throw new Error(`Unsupported TURN server URL: ${turnServers}`);
}
await waitForPort(hostname, turnPort, { timeoutMs: 30_000 });
}
const server = Deno.serve(
{
hostname: "127.0.0.1",
onListen: () => {},
port,
},
serveBrowserApplications,
);
const browser = await chromium.launch({ headless: true });
const webAppContext = await browser.newContext();
const webPeerContext = await browser.newContext();
const webAppPage = await webAppContext.newPage();
const webPeerPage = await webPeerContext.newPage();
const assertNoWebAppFailures = observePageFailures(webAppPage);
const assertNoWebPeerFailures = observePageFailures(webPeerPage);
const baseUrl = `http://127.0.0.1:${port}/`;
const pickerScript = `
const rootName = ${JSON.stringify(rootName)};
const notePath = ${JSON.stringify(notePath)};
const noteContent = ${JSON.stringify(noteContent)};
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
const originRoot = await navigator.storage.getDirectory();
try {
await originRoot.removeEntry(rootName, { recursive: true });
} catch (error) {
if (error?.name !== "NotFoundError") throw error;
}
const selectedRoot = await originRoot.getDirectoryHandle(rootName, { create: true });
const note = await selectedRoot.getFileHandle(notePath, { create: true });
const writable = await note.createWritable();
await writable.write(noteContent);
await writable.close();
return selectedRoot;
},
});
`;
await webAppPage.addInitScript(pickerScript);
try {
await webPeerPage.goto(new URL("webpeer/", baseUrl).href);
await webPeerPage.getByRole("heading", {
name: "Peer to Peer Replicator",
exact: true,
}).waitFor({
timeout: 30_000,
});
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-initial.png",
);
await configureP2PPane(webPeerPage.locator(".control"), {
deviceName: webPeerDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
});
await webPeerPage.getByRole("button", { name: "Connect", exact: true })
.click();
await webPeerPage.getByText("Connected to Signaling Server", {
exact: false,
}).waitFor({
timeout: 30_000,
});
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-configured.png",
);
await webAppPage.goto(new URL("webapp/webapp.html", baseUrl).href);
await webAppPage.locator("#vault-selector").waitFor({
state: "visible",
timeout: 30_000,
});
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-root-selection.png",
);
await webAppPage.getByRole("button", {
name: "Choose new vault folder",
exact: true,
}).click();
await webAppPage.locator("#vault-selector").waitFor({
state: "hidden",
timeout: 30_000,
});
const webAppP2P = webAppPage.locator(".p2p-control");
await webAppP2P.getByRole("heading", {
name: "Peer to Peer Replicator",
exact: true,
}).waitFor();
await configureP2PPane(webAppP2P, {
deviceName: webAppDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
});
await webAppP2P.getByRole("button", {
name: "Scan local files",
exact: true,
}).click();
await webAppP2P
.getByRole("status")
.filter({ hasText: "Local files are ready for synchronisation." })
.waitFor({ timeout: 30_000 });
await webAppP2P.getByRole("button", { name: "Connect", exact: true })
.click();
await webAppP2P.getByText("Connected to Signaling Server", {
exact: false,
}).waitFor({
timeout: 30_000,
});
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-configured.png",
);
const webAppPeerRow = webPeerPage.locator("table.peers tbody tr").filter({
hasText: webAppDeviceName,
});
await webAppPeerRow.waitFor({ timeout: 30_000 });
await webAppPeerRow.getByRole("button", { name: "Accept", exact: true })
.click();
await webAppPeerRow.getByRole("button", { name: "🔄", exact: true })
.click();
const webAppConnectionRequest = webAppPage.locator(
"dialog.vpk-browser-dialog[open]",
);
await webAppConnectionRequest.waitFor({
state: "visible",
timeout: 30_000,
});
assertEquals(
await webAppConnectionRequest.getByRole("heading").innerText(),
"P2P Connection Request",
);
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-connection-request.png",
false,
);
await webAppConnectionRequest
.getByRole("button", { name: "Accept", exact: true })
.evaluate((button: { click(): void }) => button.click());
await webAppConnectionRequest.waitFor({
state: "hidden",
timeout: 5_000,
});
try {
await waitForWebPeerLog(
webPeerPage,
"P2P Replication has been done",
60_000,
);
} catch (error) {
throw new Error(
`${formatError(error)}\n\nWebApp state:\n${await webAppPage.locator(
"body",
).innerText()}`,
);
}
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-webpeer-replicated.png",
);
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-webapp-replicated.png",
);
await webAppP2P.getByRole("button", { name: "Disconnect", exact: true })
.click();
await webAppP2P.getByText("No Connection", { exact: true }).waitFor({
timeout: 30_000,
});
await webAppContext.close();
const cliSync = startCliInBackground(
cliDatabasePath,
"--settings",
cliSettingsPath,
"p2p-sync",
webPeerDeviceName,
"20",
);
const cliPeerRow = webPeerPage.locator("table.peers tbody tr").filter({
hasText: cliDeviceName,
});
try {
await cliPeerRow.waitFor({ timeout: 20_000 });
const webPeerConnectionRequest = webPeerPage.locator(
"dialog.vpk-browser-dialog[open]",
);
await webPeerConnectionRequest.waitFor({
state: "visible",
timeout: 20_000,
});
assertEquals(
await webPeerConnectionRequest.getByRole("heading").innerText(),
"P2P Connection Request",
);
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-cli-connection-request.png",
false,
);
await webPeerConnectionRequest
.getByRole("button", { name: "Accept", exact: true })
.evaluate((button: { click(): void }) => button.click());
await webPeerConnectionRequest.waitFor({
state: "hidden",
timeout: 5_000,
});
const syncExitCode = await waitForBackgroundExit(cliSync, 45_000);
assertEquals(syncExitCode, 0, cliSync.combined);
} catch (error) {
await cliSync.stop();
throw new Error(
`${
formatError(error)
}\n\nCLI output:\n${cliSync.combined}\n\nWebPeer logs:\n${await webPeerLogs(
webPeerPage,
)}`,
);
}
const catResult = await runCli(
cliDatabasePath,
"--settings",
cliSettingsPath,
"cat",
notePath,
);
assertEquals(catResult.code, 0, catResult.combined);
assertEquals(sanitiseCatStdout(catResult.stdout), noteContent);
assertStringIncludes(catResult.stderr, "[Command] cat");
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-cli-completed.png",
);
assertNoWebAppFailures();
assertNoWebPeerFailures();
} finally {
await webAppContext.close().catch(() => {});
await webPeerContext.close().catch(() => {});
await browser.close();
await server.shutdown();
}
},
});
+33
View File
@@ -0,0 +1,33 @@
const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url));
const composeArgs = ["compose", "-f", "test/browser-apps/compose.yml"];
async function runDocker(args: string[]): Promise<Deno.CommandStatus> {
return await new Deno.Command("docker", {
args,
cwd: repositoryRoot,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).spawn().status;
}
let testStatus: Deno.CommandStatus | undefined;
try {
testStatus = await runDocker([...composeArgs, "run", "--build", "--rm", "browser-apps-interop"]);
} finally {
const cleanupStatus = await runDocker([...composeArgs, "down", "-v", "--remove-orphans"]);
if (!cleanupStatus.success) {
console.error(`[Browser applications E2E] Compose cleanup failed with exit code ${cleanupStatus.code}.`);
if (testStatus?.success) {
Deno.exit(cleanupStatus.code);
}
}
}
if (!testStatus?.success) {
const code = testStatus?.code ?? 1;
console.error(`[Browser applications E2E] Compose interoperability test failed with exit code ${code}.`);
Deno.exit(code);
}
console.log("\n[Browser applications E2E] Compose interoperability test passed.");
@@ -0,0 +1,161 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium } from "playwright";
import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts";
const webAppDist = resolve(import.meta.dirname!, "../../../src/apps/webapp/dist");
Deno.test({
name: "WebApp: production bundle selects a Vault and preserves the main remote while saving P2P settings",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webAppDist);
const browser = await chromium.launch({ headless: true });
try {
const cancellationPage = await browser.newPage();
const assertNoCancellationFailures = observePageFailures(cancellationPage);
await cancellationPage.addInitScript(`
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
throw new DOMException("Cancelled by WebApp browser test", "AbortError");
},
});
`);
await cancellationPage.goto(new URL("webapp.html", server.baseUrl).href);
await cancellationPage.locator("#status").filter({ hasText: "Select a vault folder" }).waitFor();
const picker = cancellationPage.getByRole("button", { name: "Choose new vault folder" });
await picker.click();
await cancellationPage
.locator("#status.warning")
.filter({ hasText: "Vault selection was cancelled" })
.waitFor();
assertEquals(await picker.isEnabled(), true);
assertEquals(await cancellationPage.locator("#vault-selector").isVisible(), true);
assertNoCancellationFailures();
await cancellationPage.close();
const runtimePage = await browser.newPage();
const assertNoRuntimeFailures = observePageFailures(runtimePage);
await runtimePage.addInitScript(`
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
const originRoot = await navigator.storage.getDirectory();
try {
await originRoot.removeEntry("livesync-webapp-smoke", { recursive: true });
} catch (error) {
if (error?.name !== "NotFoundError") throw error;
}
return await originRoot.getDirectoryHandle("livesync-webapp-smoke", { create: true });
},
});
`);
await runtimePage.goto(new URL("webapp.html", server.baseUrl).href);
await runtimePage.getByRole("button", { name: "Choose new vault folder" }).click();
await runtimePage.locator("#vault-selector").waitFor({ state: "hidden", timeout: 30_000 });
await runtimePage.waitForFunction("globalThis.livesyncApp?.getRuntime() != null");
await runtimePage.locator("#status.warning").filter({ hasText: "Please configure CouchDB" }).waitFor();
const state = await runtimePage.evaluate(async () => {
const app = (
globalThis as typeof globalThis & {
livesyncApp?: {
getRuntime(): unknown;
historyStore: {
getVaultHistory(): Promise<Array<{ name: string }>>;
};
};
}
).livesyncApp;
return {
historyNames: (await app!.historyStore.getVaultHistory()).map((item) => item.name),
runtimeStarted: app!.getRuntime() != null,
};
});
assertEquals(state, {
historyNames: ["livesync-webapp-smoke"],
runtimeStarted: true,
});
await runtimePage.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor();
await runtimePage.evaluate(() => {
const runtime = (
globalThis as typeof globalThis & {
livesyncApp?: {
getRuntime(): {
events: {
emitEvent(name: string, payload: unknown): void;
};
} | null;
};
}
).livesyncApp?.getRuntime();
runtime?.events.emitEvent("p2p-server-status", {
isConnected: true,
knownAdvertisements: [
{
isAccepted: true,
name: "Peer without WebApp menu",
peerId: "peer-without-webapp-menu",
},
],
serverPeerId: "webapp-browser-smoke",
});
});
await runtimePage.getByText("Peer without WebApp menu", { exact: true }).waitFor();
assertEquals(
await runtimePage.getByRole("button", { name: "...", exact: true }).count(),
0,
"WebApp must not render a peer-menu action without a menu capability"
);
await runtimePage.locator(".p2p-control input[type='checkbox']").first().check();
await runtimePage
.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx")
.fill("ws://127.0.0.1:4010/");
await runtimePage.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await runtimePage.getByPlaceholder("password").fill("browser-e2e-passphrase");
await runtimePage.getByPlaceholder("iphone-16").fill("browser-e2e-webapp");
const save = runtimePage.getByRole("button", { name: "Save and Apply", exact: true });
await save.click();
await waitFor(async () => await save.isDisabled(), "WebApp did not finish applying P2P settings");
const primaryRemote = await runtimePage.evaluate(() => {
const runtime = (
globalThis as typeof globalThis & {
livesyncApp?: {
getRuntime(): {
p2pPaneHost: {
services: {
setting: {
currentSettings(): {
isConfigured: boolean;
remoteType: string;
};
};
};
};
} | null;
};
}
).livesyncApp?.getRuntime();
const settings = runtime?.p2pPaneHost.services.setting.currentSettings();
return {
isConfigured: settings?.isConfigured,
remoteType: settings?.remoteType,
};
});
assertEquals(primaryRemote, {
isConfigured: false,
remoteType: "",
});
await runtimePage.getByRole("button", { name: "Scan local files", exact: true }).waitFor();
assertNoRuntimeFailures();
} finally {
await browser.close();
await server.close();
}
},
});
@@ -0,0 +1,58 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium } from "playwright";
import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts";
const webPeerDist = resolve(import.meta.dirname!, "../../../src/apps/webpeer/dist");
Deno.test({
name: "WebPeer: production bundle starts and reloads its persisted settings",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webPeerDist);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const assertNoPageFailures = observePageFailures(page);
await page.goto(server.baseUrl);
await page.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({
timeout: 30_000,
});
await page.getByText("No Connection", { exact: true }).waitFor();
await page.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await page.getByPlaceholder("iphone-16").fill("browser-e2e-peer");
await page.getByText("Optional TURN server settings", { exact: true }).click();
await page.getByPlaceholder("turn:turn.example.com:3478").fill("turn:127.0.0.1:3478");
await page.getByPlaceholder("Enter TURN username").fill("browser-turn-user");
await page.getByPlaceholder("Enter TURN credential").fill("browser-turn-credential");
const saveTurn = page.getByRole("button", { name: "Save TURN settings", exact: true });
await saveTurn.click();
await waitFor(async () => await saveTurn.isDisabled(), "WebPeer did not finish applying its TURN settings");
const save = page.getByRole("button", { name: "Save and Apply", exact: true });
await save.click();
await waitFor(async () => await save.isDisabled(), "WebPeer did not finish applying its settings");
await page.reload();
await page.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({
timeout: 30_000,
});
assertEquals(await page.getByPlaceholder("anything-you-like").inputValue(), "browser-e2e-room");
assertEquals(await page.getByPlaceholder("iphone-16").inputValue(), "browser-e2e-peer");
await page.getByText("Optional TURN server settings", { exact: true }).click();
assertEquals(await page.getByPlaceholder("turn:turn.example.com:3478").inputValue(), "turn:127.0.0.1:3478");
assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user");
assertEquals(
await page.getByPlaceholder("Enter TURN credential").inputValue(),
"browser-turn-credential"
);
assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true);
assertNoPageFailures();
} finally {
await browser.close();
await server.close();
}
},
});