element-web/apps/web/playwright/pages/ElementAppPage.ts
Will Hunt 38c3d4f8a3
Playwright test for pasting files (#33350)
* Add tests for pasting

* Add tests for pasting files.

* Remove redundant fn

* rm comment
2026-05-01 11:25:49 +00:00

406 lines
15 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page, expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { Settings } from "./settings";
import { Client } from "./client";
import { Timeline } from "./timeline";
import { Spotlight } from "./Spotlight";
/**
* A set of utility methods for interacting with the Element-Web UI.
*/
export class ElementAppPage {
public constructor(public readonly page: Page) {}
// We create these lazily on first access to avoid calling setup code which might cause conflicts,
// e.g. the network routing code in the client subfixture.
private _settings?: Settings;
public get settings(): Settings {
if (!this._settings) this._settings = new Settings(this.page);
return this._settings;
}
private _client?: Client;
public get client(): Client {
if (!this._client) this._client = new Client(this.page);
return this._client;
}
private _timeline?: Timeline;
public get timeline(): Timeline {
if (!this._timeline) this._timeline = new Timeline(this.page);
return this._timeline;
}
public async cleanup() {
await this._client?.cleanup();
}
/**
* Open the top left user menu, returning a Locator to the resulting context menu.
*/
public async openUserMenu(): Promise<Locator> {
return this.settings.openUserMenu();
}
/**
* Open room creation dialog.
*/
public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise<Locator> {
await this.page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await this.page.getByRole("menuitem", { name: roomKindname }).click();
return this.page.locator(".mx_CreateRoomDialog");
}
/**
* Close dialog currently open dialog
*/
public async closeDialog(): Promise<void> {
return this.settings.closeDialog();
}
public async getClipboard(): Promise<string> {
return await this.page.evaluate(() => navigator.clipboard.readText());
}
/**
* Get the room ID from the current URL.
*
* @returns The room ID.
* @throws if the current URL does not contain a room ID.
*/
public async getCurrentRoomIdFromUrl(): Promise<string> {
const urlHash = await this.page.evaluate(() => window.location.hash);
if (!urlHash.startsWith("#/room/")) {
throw new Error("URL hash suggests we are not in a room");
}
return urlHash.replace("#/room/", "");
}
/**
* Opens the given room by name. The room must be visible in the
* room list and the room may contain unread messages.
*
* @param name The exact room name to find and click on/open.
*/
public async viewRoomByName(name: string): Promise<void> {
// We get the room list by test-id which is a listbox and matching title=name
return this.page.getByTestId("room-list").locator(`[title="${name}"]`).first().click();
}
/**
* Opens the given room on the old room list by name. The room must be visible in the
* room list, but the room list may be folded horizontally, and the
* room may contain unread messages.
*
* @param name The exact room name to find and click on/open.
*/
public async viewRoomByNameOnOldRoomList(name: string): Promise<void> {
// We look for the room inside the room list, which is a tree called Rooms.
//
// There are 3 cases:
// - the room list is folded:
// then the aria-label on the room tile is the name (with nothing extra)
// - the room list is unfolder and the room has messages:
// then the aria-label contains the unread count, but the title of the
// div inside the titleContainer equals the room name
// - the room list is unfolded and the room has no messages:
// then the aria-label is the name and so is the title of a div
//
// So by matching EITHER title=name OR aria-label=name we find this exact
// room in all three cases.
return this.page
.getByRole("tree", { name: "Rooms" })
.locator(`[title="${name}"],[aria-label="${name}"]`)
.first()
.click();
}
public async viewRoomById(roomId: string): Promise<void> {
await this.page.goto(`/#/room/${roomId}`);
}
/**
* Get the composer element
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public getComposer(isRightPanel?: boolean): Locator {
const panelClass = isRightPanel ? ".mx_RightPanel" : ".mx_RoomView_body";
return this.page.locator(`${panelClass} .mx_MessageComposer`);
}
/**
* Get the composer input field
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public getComposerField(isRightPanel?: boolean): Locator {
return this.getComposer(isRightPanel).locator("div[contenteditable]");
}
/**
* Open the message composer kebab menu
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public async openMessageComposerOptions(isRightPanel?: boolean): Promise<Locator> {
const composer = this.getComposer(isRightPanel);
await composer.getByRole("button", { name: "More options", exact: true }).click();
return this.page.getByRole("menu");
}
/**
* Sets the files on the composers file input, causing it to open the file
* upload dialog.
* @param location Should the main room input or the thread view input be used.
*/
public setComposerInputFiles(
location: "room" | "thread",
...params: Parameters<Locator["setInputFiles"]>
): ReturnType<Locator["setInputFiles"]> {
const input = this.page
.locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel")
.getByRole("region", { name: "Message composer" })
.locator("input[type='file']");
return input.setInputFiles(...params);
}
/**
* Sets the files on the composers file input, causing it to open the file
* upload dialog, and then automaticlly submits the dialog that pops up which
* causes the file to be uploaded.
* @param location Should the main room input or the thread view input be used.
*/
public async composerUploadFiles(
location: "room" | "thread",
...params: Parameters<Locator["setInputFiles"]>
): Promise<void> {
await this.setComposerInputFiles(location, ...params);
await this.page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
}
/**
* Drags a "file" into the specified composer and automatically uploads it.
* @param location Should the drop target the main room or the thread.
* @param path The path to the sample file so it can be read.
* @param type The mimetype of the file.
*/
public async composerDragAndUploadFiles(location: "room" | "thread", path: string, type: string): Promise<void> {
// Based on https://github.com/microsoft/playwright/issues/10667#issuecomment-2742123424
// This read a file, encodes it into base64 and then sends it along to the page to be treated
// as a DataTransfer (the mechanism for drag and dropped files).
const buffer = await readFile(path);
const name = basename(path);
const dataTransfer = await this.page.evaluateHandle(
async ([buffer, name, type]) => {
const dt = new DataTransfer();
const file = new File([Uint8Array.fromBase64(buffer)], name, {
type,
});
dt.items.add(file);
return dt;
},
[buffer.toString("base64"), name, type],
);
await this.page.dispatchEvent(location === "room" ? ".mx_RoomView_body" : ".mx_ThreadPanel", "drop", {
dataTransfer,
});
await this.page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
}
/**
* Paste a "file" into the specified locator and automatically uploads it.
* @param location Should the drop target the main room or the thread.
* @param path The path to the sample file so it can be read.
* @param type The mimetype of the file.
*/
public async composerDragAndPasteFile(location: "room" | "thread", path: string, type: string): Promise<void> {
// Based on https://github.com/microsoft/playwright/issues/10667#issuecomment-2742123424
// This read a file, encodes it into base64 and then sends it along to the page to be treated
// as a DataTransfer (the mechanism for drag and dropped files).
const buffer = await readFile(path);
const name = basename(path);
const composer = this.getComposerField(location === "thread");
await composer.evaluate(
async (element, [buffer, name, type]) => {
const clipboardData = new DataTransfer();
const file = new File([Uint8Array.fromBase64(buffer)], name, {
type,
});
clipboardData.items.add(file);
element.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData,
bubbles: true,
cancelable: true,
}),
);
},
[buffer.toString("base64"), name, type],
);
await this.page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
}
/**
* Returns the space panel space button based on a name. The space
* must be visible in the space panel
* @param name The space name to find
*/
public async getSpacePanelButton(name: string): Promise<Locator> {
const button = this.page.getByRole("button", { name: name });
await expect(button).toHaveClass(/mx_SpaceButton/);
return button;
}
/**
* Opens the given space home by name. The space must be visible in
* the space list.
* @param name The space name to find and click on/open.
*/
public async viewSpaceHomeByName(name: string): Promise<void> {
const button = await this.getSpacePanelButton(name);
return button.dblclick();
}
/**
* Opens the given space by name. The space must be visible in the
* space list.
* @param name The space name to find and click on/open.
*/
public async viewSpaceByName(name: string): Promise<void> {
const button = await this.getSpacePanelButton(name);
return button.click();
}
public async openSpotlight(): Promise<Spotlight> {
const spotlight = new Spotlight(this.page);
await spotlight.open();
return spotlight;
}
/**
* Opens/closes the room info panel
* @returns locator to the right panel
*/
public async toggleRoomInfoPanel(): Promise<Locator> {
await this.page.getByRole("button", { name: "Room info" }).first().click();
return this.page.locator(".mx_RightPanel");
}
/**
* Opens the room info panel if it is not already open.
*
* TODO: fix this so that it works correctly if, say, the member list was open instead of the room info panel.
*
* @returns locator to the right panel
*/
public async openRoomInfoPanel(): Promise<Locator> {
const locator = this.page.getByTestId("right-panel");
if (!(await locator.isVisible())) {
await this.page.getByRole("button", { name: "Room info" }).first().click();
}
return locator;
}
/**
* Opens/closes the memberlist panel
* @returns locator to the memberlist panel
*/
public async toggleMemberlistPanel(): Promise<Locator> {
const locator = this.page.locator(".mx_FacePile");
await locator.click();
const memberlist = this.page.locator(".mx_MemberListView");
await memberlist.waitFor();
return memberlist;
}
/**
* Open the room info panel, and use it to send an invite to the given user.
*
* @param userId - The user to invite to the room.
* @param options - Options object
*/
public async inviteUserToCurrentRoom(
userId: string,
options?: {
/** If true, expect and acknowledge "Confirm inviting new users" page */
confirmUnknownUser?: boolean;
},
): Promise<void> {
const rightPanel = await this.openRoomInfoPanel();
await rightPanel.getByRole("menuitem", { name: "Invite" }).click();
const dialogLocator = this.page.getByRole("dialog");
const input = dialogLocator.getByTestId("invite-dialog-input");
await input.fill(userId);
await input.press("Enter");
await dialogLocator.getByRole("button", { name: "Invite" }).click();
if (options?.confirmUnknownUser) {
await expect(
dialogLocator.getByRole("heading", { name: "Invite new contacts to this room?" }),
).toBeVisible();
await dialogLocator.getByRole("button", { name: "Invite" }).click();
}
}
async closeToast(title: string, button: string): Promise<void> {
await this.page.locator(".mx_Toast_toast", { hasText: title }).getByRole("button", { name: button }).click();
}
/**
* Dismiss the "Notifications" toast.
*/
public async closeNotificationToast(): Promise<void> {
await this.closeToast("Notifications", "Dismiss");
}
/**
* Dismiss the "Turn on key storage" toast.
*/
public async closeKeyStorageToast() {
await this.closeToast("Turn on key storage", "Dismiss");
await this.page.getByRole("button", { name: "Yes, dismiss" }).click();
}
/**
* Dismiss the "Verify this device" toast by clicking "Later".
*/
public async closeVerifyToast() {
await this.closeToast("Verify this device", "Later");
}
/**
* Scroll an infinite list to the bottom.
* @param list The element to scroll
*/
public async scrollListToBottom(list: Locator): Promise<void> {
// First hover the mouse over the element that we want to scroll
await list.hover();
const needsScroll = async () => {
// From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const fullyScrolled = await list.evaluate(
(e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1,
);
return !fullyScrolled;
};
// Scroll the element until we detect that it is fully scrolled
do {
await this.page.mouse.wheel(0, 1000);
} while (await needsScroll());
}
}