diff --git a/ts/packages/web-console/test/e2e/room-messaging.spec.ts b/ts/packages/web-console/test/e2e/room-messaging.spec.ts new file mode 100644 index 0000000..e15a886 --- /dev/null +++ b/ts/packages/web-console/test/e2e/room-messaging.spec.ts @@ -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 { + return chromium.launch({ args: SAME_MACHINE_WEBRTC_ARGS }); +} + +async function newDevicePage(browser: Readonly): Promise { + 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, + address: string, +): Promise { + 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, + browserB: Readonly, + appUrl: string, +): Promise { + 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((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, + }); +}