mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-30 08:23:01 +00:00
refactor: compose browser application runtimes
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+55
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)})`)
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user