Files
computer-playbook/roles/web-app-matrix/files/playwright/test-dm-admin-biber.js
kevinveenbirkenbachandClaude Opus 5 df19dd103c fix(matrix): match the invite tile on the button, not on a row that is absent
9661800cb0 scoped the tile to [role="row"][aria-level="2"], taken from
upstream's test helpers for Element 1.12.28. meta/services.yml pinned v1.12.27
then and has never held 1.12.28 on any ref, so the selector described markup
the deployment does not render.

Read out of biber's DOM in the trace of the failing job: role="row" occurs zero
times, aria-expanded zero times, aria-label="Toggle " zero times, and every
aria-level in the document is "1" on a heading. The only aria-level="2" in the
whole trace is inside the test's own injected script. The invite is a bare
button in a virtuoso list:

  DIV[data-testid="virtuoso-item-list"] > DIV[data-index="0"]
    > BUTTON[aria-label="Open room Administrator Infinito.Nexus invitation."]

So the tile was never clicked, biber stayed on the welcome screen, and the poll
spent its 120 seconds on a page that could not change. The screenshots of all
three attempts show the same state: the invite row visible in the list, a
device-verification prompt beside it, no room open.

Matching the button directly drops the dependency on the wrapper. The label
prefix and the partner match are unchanged, and "Open room settings" is the
only other button carrying the prefix, which the partner match excludes.

The section expansion stays. It is a no-op against 1.12.27, where nothing
reports aria-expanded, and it is what upstream's helpers describe for the
release after it.

Not verified: the accept step behind the tile. biber never reached the invite
view in any attempt, so which of "Start chatting", "Accept" or "Join" 1.12.27
renders there is unobserved, and the anchored regex covering all three is
carried over untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 03:49:39 +02:00

168 lines
8.2 KiB
JavaScript

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.<primary-domain>") 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();
});
});
};