const { test, expect } = require("./onion-test"); const { resolveTimeout } = require("./timeouts"); // Synapse refills rc_login on its own clock, so the onion multiplier must not stretch these waits. const RC_LOGIN_DRAIN_MS = 120_000; const RC_LOGIN_REFILL_MS = 30_000; exports.register = function (shared) { test.describe("matrix DM", () => { test("administrator and biber can exchange a direct message in element", async ({ browser }) => { shared.skipUnlessServiceEnabled("sso"); const { adminUsername, adminPassword, biberUsername, biberPassword, elementBaseUrl, matrixServerName, } = shared.env; const adminContext = await browser.newContext({ ignoreHTTPSErrors: true }); const biberContext = await browser.newContext({ ignoreHTTPSErrors: true }); const adminPage = await adminContext.newPage(); const biberPage = await biberContext.newPage(); await shared.installCspViolationObserver(adminPage); await shared.installCspViolationObserver(biberPage); // When the spec runs in full (CSP + two per-user OIDC tests + DM), the // prior admin/biber logins have already consumed Synapse's rc_login // burst (default: 3 slots, drain 0.17/s ≈ 6s per slot). The per-user // OIDC tests themselves can take minutes and trigger retries that // consume additional slots. Waiting ~120s before DM's admin sign-in // lets the burst and any pending retries fully drain, so the DM // test's state machine isn't forced to spend most of its deadline // cycling through consent↔M_LIMIT_EXCEEDED retries. Running the DM // test in isolation doesn't need this, but the extra wait is cheap. await adminPage.waitForTimeout(RC_LOGIN_DRAIN_MS); await shared.signInViaElement(adminPage, adminUsername, adminPassword, "administrator"); // Same reasoning between admin and biber: two back-to-back SSO // logins easily exhaust rc_login. 30s lets the burst refill. await adminPage.waitForTimeout(RC_LOGIN_REFILL_MS); await shared.signInViaElement(biberPage, biberUsername, biberPassword, "biber"); const marker = `hello-from-admin-${Date.now()}`; // MXIDs use the Synapse server_name (a.k.a. MATRIX_SERVER_NAME, typically // the bare DOMAIN_PRIMARY), not the client-facing URL host. Using the URL // host (e.g. "matrix.") yields a non-existent user and // Synapse returns HTTP 502 on profile lookup. const biberMatrixId = `@${biberUsername}:${matrixServerName}`; // Admin opens a DM to biber via URL API (#/user/@biber:...). This renders // biber's profile in a right-hand `aside` panel alongside the home // welcome screen — not a full-screen user view — so the "Send message" // button we need lives inside the profile panel specifically (there is a // separate "Send a Direct Message" button on the welcome screen that // opens a search dialog rather than directly messaging biber). const profilePanel = adminPage.getByRole("complementary").filter({ hasText: biberMatrixId }); await shared.gotoElementApp( adminPage, `${elementBaseUrl}/#/user/${encodeURIComponent(biberMatrixId)}`, profilePanel ); await expect(profilePanel, "admin: biber profile panel must render").toBeVisible({ timeout: resolveTimeout(60_000) }); const profileSendMessageButton = profilePanel .getByRole("button", { name: /^send message$/i }) .first(); await expect(profileSendMessageButton, "admin: profile 'Send message' button must be visible").toBeVisible({ timeout: resolveTimeout(30_000) }); await profileSendMessageButton.click(); // After clicking "Send message", Element navigates into the DM room and // renders a message composer. Wait for the room header to show biber's // name before looking for the composer, so we don't match a stale // textbox from the previous view (e.g. a search field). const roomHeader = adminPage.getByRole("heading", { name: /harry beaver|biber/i }).first(); await expect(roomHeader, "admin: DM room header with biber must render").toBeVisible({ timeout: resolveTimeout(30_000) }); const composer = adminPage .locator("div[role='textbox'][contenteditable='true'], textarea[aria-label*='message' i], div[aria-label*='message' i][contenteditable='true']") .last(); await expect(composer, "admin: message composer must appear").toBeVisible({ timeout: resolveTimeout(60_000) }); await composer.click(); // Element keeps the DM in a pending "Send your first message to invite …" // state when entered via a profile's "Send message" button: the room // (and therefore the invite to biber) is only created on the server once // admin actually sends. Without this bootstrap send, biber's side never // receives an invite tile and the accept-invite poll below times out. // // The bootstrap text is intentionally distinct from `marker`. With E2EE // enabled and biber not yet joined, this first ciphertext will land on // biber's side as "Unable to decrypt message" (no pre-join megolm key // share). That's acceptable — biber's assertion targets `marker`, which // admin sends AFTER biber has joined so Element establishes a fresh // outbound megolm session that includes biber's device. const bootstrap = `bootstrap-${Date.now()}`; await adminPage.keyboard.type(bootstrap); await adminPage.keyboard.press("Enter"); await expect .poll(async () => { return await biberPage.evaluate(() => { const sections = document.querySelectorAll( 'button[aria-expanded="false"][aria-label^="Toggle "]', ); for (const section of sections) { section.click(); } // Not scoped to a row wrapper: the list is virtualised and 1.12.27 // renders no role=row, aria-level or aria-expanded at all. const roomTiles = document.querySelectorAll( 'button[aria-label^="Open room"]', ); for (const tile of roomTiles) { const name = `${tile.getAttribute("aria-label") || ""} ${tile.textContent || ""}`; if (/administrator/i.test(name)) { tile.click(); break; } } // Anchored: "Decline and block" contains an accept-looking word and // clicking it destroys the invite. const acceptCandidates = document.querySelectorAll( "button, a, [role='button']", ); for (const b of acceptCandidates) { const text = (b.textContent || "").trim(); if (/^(start chatting|accept|accept invite|join|annehmen|akzeptieren)$/i.test(text)) { b.click(); } } return !!document.querySelector( "div[role='textbox'][contenteditable='true'], textarea[aria-label*='message' i]", ); }).catch(() => false); }, { timeout: resolveTimeout(120_000), message: "biber: expected invite acceptance to produce a message composer", }) .toBe(true); // Give admin's client a moment to observe biber's join and establish a // megolm session before sending the marker. Element batches outbound // session creation on membership events; 5s is generous. await adminPage.waitForTimeout(resolveTimeout(5_000)); await composer.click(); await adminPage.keyboard.type(marker); await adminPage.keyboard.press("Enter"); // Biber should now receive the marker as a live (not historical) // message and decrypt it via the just-shared megolm session. await expect .poll(async () => { return (await biberPage.locator("body").innerText().catch(() => "")).includes(marker); }, { timeout: resolveTimeout(120_000), message: `biber: expected to receive message "${marker}" from administrator`, }) .toBe(true); await adminContext.close(); await biberContext.close(); }); }); };