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
+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();
}
},
});