Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions ts/packages/web-console/test/e2e/room-messaging.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// A real, checked-in end-to-end test for the actual console UI's room-messaging flow (wire-mesh#101) -- webrtc.spec.ts already proves the raw WebRTC/relay signaling works against a bare test harness page; this drives the real production App/ConnectionPanel/RoomPanel components instead, the way a person actually uses the console: two independent browser instances, each its own persisted identity, connect to a real relay, one clicks "Message" on the other's directory row, the other approves the resulting request, a message is sent, and it renders on the receiving side -- with no manually copy-pasted token anywhere in the flow.
//
// Currently skipped: confirmed directly (running this exact test) that relay-hub never forwards a received gossip peer-advert to any other connected client -- it only registers the device internally for its own relay-connect target lookups (relay-hub.ts's handleFrame, the "gossip" branch). Two consoles connected to the same wire-mesh-node relay therefore never populate each other's Peer directory at all; webrtc.spec.ts's own harness only works around this by exchanging device-ids directly between the two Playwright page contexts in the test process, not something a real end user's UI can do. Filed as wire-mesh#110, blocking this issue (#101). Unskip once #110 lands -- this test should then pass unmodified, since it drives the real UI throughout and never depends on the workaround the harness test uses.
//
// The RoomPanel this test drives into existence only ever appears once negotiator.initiate()'s real WebRTC offer/answer/ICE exchange actually reaches a connected data channel -- the same host-network caveat webrtc.spec.ts's own module comment documents (ICE may not reach "connected" on a host whose only routable interface refuses to hairpin a loopback UDP packet, independent of Chromium/WebRTC). This test asserts directory visibility (which only needs the relay's WebSocket signaling, not ICE) unconditionally, then races the RoomPanel's appearance against a generous timeout and skips the rest of the flow gracefully -- with an annotation, not a failure -- if this specific environment can't complete ICE, exactly mirroring webrtc.spec.ts's own data-channel-open race.

import {
type Browser,
type Page,
chromium,
expect,
test,
} from "@playwright/test";
import { RELAY_ADDRESS, VITE_PORT } from "../../playwright.config.js";

// Mirrors playwright.config.ts's own `use.launchOptions.args` -- see webrtc.spec.ts's identical constant for why a directly-launched browser needs these repeated explicitly.
const SAME_MACHINE_WEBRTC_ARGS = [
"--disable-features=WebRtcHideLocalIpsWithMdns",
"--allow-loopback-in-peer-connection",
];

const APP_URL_PATH = "/";
const DIRECTORY_TIMEOUT_MS = 15_000;
const ROOM_PANEL_TIMEOUT_MS = 15_000;
const MESSAGE_RENDER_TIMEOUT_MS = 10_000;
const TEST_TIMEOUT_MS = 45_000;
const TEST_MESSAGE_TEXT = "hello from the e2e test";

/** A genuinely separate Chromium process per device, matching webrtc.spec.ts's own launchDeviceBrowser -- separate storage/IndexedDB/identity, and no shared browser process either, so each side's createPersistedWebCryptoIdentity() call in main.tsx mints its own distinct device-id. */
async function launchDeviceBrowser(): Promise<Browser> {
return chromium.launch({ args: SAME_MACHINE_WEBRTC_ARGS });
}

async function newDevicePage(browser: Readonly<Browser>): Promise<Page> {
const context = await browser.newContext();
const page = await context.newPage();
page.on("pageerror", (error) => {
throw error;
});
return page;
}

/** Fills the console's own connect form and submits it -- the real UI path, not a scripted session.connect() call. */
async function connectConsole(
page: Readonly<Page>,
address: string,
): Promise<void> {
await page.getByLabel("Node").fill(address);
await page.getByRole("button", { name: "Connect" }).click();
}

test.skip("two independent console instances see each other, message, approve, and render the reply through the real UI (blocked by wire-mesh#110)", async () => {
test.setTimeout(TEST_TIMEOUT_MS);
const appUrl = new URL(
APP_URL_PATH,
`http://localhost:${String(VITE_PORT)}`,
).toString();

const browserA = await launchDeviceBrowser();
const browserB = await launchDeviceBrowser();
try {
await runRoomMessagingTest(browserA, browserB, appUrl);
} finally {
await Promise.all([browserA.close(), browserB.close()]);
}
});

async function runRoomMessagingTest(
browserA: Readonly<Browser>,
browserB: Readonly<Browser>,
appUrl: string,
): Promise<void> {
const pageA = await newDevicePage(browserA);
const pageB = await newDevicePage(browserB);

await pageA.goto(appUrl);
await pageB.goto(appUrl);

await connectConsole(pageA, RELAY_ADDRESS);
await connectConsole(pageB, RELAY_ADDRESS);

// Each side's own gossip self-advert must reach the relay and be forwarded to the other before either directory table shows a "Message" button at all -- the same real, connection-independent settle time webrtc.spec.ts's own GOSSIP_SETTLE_MS documents, expressed here as a poll rather than a fixed sleep since this is the real UI re-rendering from useMeshSessionEvents. Scoped to the "Message" button specifically, not a generic table row -- ConnectionPanel also renders a separate frame-log table, whose own rows carry no such button but would otherwise be indistinguishable from a directory row by a bare "table tbody tr" locator.
const messageButtonA = pageA.getByRole("button", { name: "Message" });
const messageButtonB = pageB.getByRole("button", { name: "Message" });
await expect(messageButtonA).toBeVisible({ timeout: DIRECTORY_TIMEOUT_MS });
await expect(messageButtonB).toBeVisible({ timeout: DIRECTORY_TIMEOUT_MS });

// A clicks "Message" on B's directory row -- this negotiates a real peer-to-peer WebRTC connection (webrtc-negotiation.ts's initiate()), which is what determines whether a RoomPanel ever appears on either side at all.
await messageButtonA.click();

const roomPanelAppeared = await Promise.race([
pageB
.getByText("wants to message you")
.waitFor({ timeout: ROOM_PANEL_TIMEOUT_MS })
.then(() => true)
.catch(() => false),
new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(false);
}, ROOM_PANEL_TIMEOUT_MS);
}),
]);

test.info().annotations.push({
type: roomPanelAppeared
? "webrtc-datachannel-opened"
: "webrtc-datachannel-not-opened",
description: roomPanelAppeared
? "RTCDataChannel reached open in this environment; full room-messaging flow exercised"
: "ICE did not complete in this environment (see module comment) -- directory visibility over the real relay was already asserted above",
});

if (!roomPanelAppeared) {
return;
}

// B approves A's message request through the real UI, not a scripted decide() call.
await pageB.getByRole("button", { name: "Allow" }).click();

// A sends a message through its own RoomPanel's real compose box.
const composeBoxA = pageA.getByPlaceholder("Message");
await composeBoxA.fill(TEST_MESSAGE_TEXT);
await pageA.getByRole("button", { name: "Send" }).click();

// The proof this flow actually works end to end: B's own RoomPanel renders the message A composed and sent, with no token ever manually copied between the two consoles.
await expect(pageB.getByText(TEST_MESSAGE_TEXT)).toBeVisible({
timeout: MESSAGE_RENDER_TIMEOUT_MS,
});
}