Playwright tests for file uploads (#33319)

* Refactor tests to use helper method for composer uploads.

* Add drag and drop tests

* lint

* Add commentary

* fixup test

* More precise selector
This commit is contained in:
Will Hunt 2026-04-30 13:49:27 +01:00 committed by GitHub
parent b0ee6f5323
commit af20018ea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 148 additions and 46 deletions

View File

@ -11,7 +11,7 @@ import type { Locator, Page } from "@playwright/test";
import { test, expect, type ExtendedToMatchScreenshotOptions } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { type ElementAppPage } from "../../pages/ElementAppPage";
import type { ElementAppPage } from "../../pages/ElementAppPage";
import { getSampleFilePath } from "../../sample-files";
// Find and click "Reply" button
@ -29,22 +29,17 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
displayName: "Hanako",
});
const uploadFile = async (page: Page, sampleFile: string) => {
const uploadFile = async (app: ElementAppPage, sampleFile: string) => {
// Upload a file from the message composer
await page
.locator(".mx_MessageComposer_actions input[type='file']")
.setInputFiles(getSampleFilePath(sampleFile));
// Find and click primary "Upload" button
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
await app.composerUploadFiles("room", getSampleFilePath(sampleFile));
// Wait until the file is sent
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(page.locator(".mx_EventTile.mx_EventTile_last").getByRole("status")).toHaveAccessibleName(
await expect(app.page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(app.page.locator(".mx_EventTile.mx_EventTile_last").getByRole("status")).toHaveAccessibleName(
"Your message was sent",
);
// wait for the tile to finish loading
await expect(page.getByTestId("audio-player-name").last().filter({ hasText: sampleFile })).toBeVisible();
await expect(app.page.getByTestId("audio-player-name").last().filter({ hasText: sampleFile })).toBeVisible();
};
const scrollToBottomOfTimeline = async (page: Page) => {
@ -157,7 +152,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
});
test("should be correctly rendered - light theme", { tag: "@screenshot" }, async ({ page, app }) => {
await uploadFile(page, "1sec-long-name-audio-file.ogg");
await uploadFile(app, "1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme)");
});
@ -165,7 +160,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
"should be correctly rendered - light theme with monospace font",
{ tag: "@screenshot" },
async ({ page, app }) => {
await uploadFile(page, "1sec-long-name-audio-file.ogg");
await uploadFile(app, "1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme, monospace font)", true); // Enable monospace
},
@ -182,7 +177,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await app.closeDialog();
await uploadFile(page, "1sec-long-name-audio-file.ogg");
await uploadFile(app, "1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (high contrast)");
});
@ -191,13 +186,13 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
// Enable dark theme
await app.settings.setValue("theme", null, SettingLevel.ACCOUNT, "dark");
await uploadFile(page, "1sec-long-name-audio-file.ogg");
await uploadFile(app, "1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (dark theme)");
});
test("should play an audio file", async ({ page, app }) => {
await uploadFile(page, "1sec.ogg");
await uploadFile(app, "1sec.ogg");
// Assert that the audio player is rendered
const container = page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" });
@ -219,7 +214,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
});
test("should support downloading an audio file", async ({ page, app }) => {
await uploadFile(page, "1sec.ogg");
await uploadFile(app, "1sec.ogg");
const downloadPromise = page.waitForEvent("download");
@ -237,7 +232,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
"should support replying to audio file with another audio file",
{ tag: "@screenshot" },
async ({ page, app }) => {
await uploadFile(page, "1sec.ogg");
await uploadFile(app, "1sec.ogg");
// Assert the audio player is rendered
await expect(page.getByRole("region", { name: "Audio player" })).toBeVisible();
@ -247,7 +242,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await clickButtonReply(tile);
// Reply to the player with another audio file
await uploadFile(page, "1sec.ogg");
await uploadFile(app, "1sec.ogg");
// Assert that the audio player is rendered
await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible();
@ -272,7 +267,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const tile = page.locator(".mx_EventTile_last");
await uploadFile(page, "upload-first.ogg");
await uploadFile(app, "upload-first.ogg");
// Assert that the audio player is rendered
await expect(
@ -282,7 +277,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await clickButtonReply(tile);
// Reply to the player with another audio file
await uploadFile(page, "upload-second.ogg");
await uploadFile(app, "upload-second.ogg");
// Assert that the audio player is rendered
await expect(
@ -292,7 +287,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await clickButtonReply(tile);
// Reply to the player with yet another audio file to create a reply chain
await uploadFile(page, "upload-third.ogg");
await uploadFile(app, "upload-third.ogg");
// Assert that the audio player is rendered
await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible();
@ -324,7 +319,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
);
test("should be rendered, play, and support replying on a thread", async ({ page, app }) => {
await uploadFile(page, "1sec-long-name-audio-file.ogg");
await uploadFile(app, "1sec-long-name-audio-file.ogg");
// On the main timeline
const messageList = page.locator(".mx_RoomView_MessageList");

View File

@ -27,12 +27,17 @@ test.describe("Image Upload", () => {
});
test("should show image preview when uploading an image", { tag: "@screenshot" }, async ({ page, app }) => {
await page
.locator(".mx_MessageComposer_actions input[type='file']")
.setInputFiles(getSampleFilePath("riot.png"));
await app.setComposerInputFiles("room", getSampleFilePath("riot.png"));
await expect(page.getByRole("button", { name: "Upload" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("image-upload-preview.png");
});
test("should allow upload via drag and drop", { tag: "@screenshot" }, async ({ page, app }) => {
await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png");
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
});
});

View File

@ -5,26 +5,21 @@ Copyright 2023 Suguru Hirahara
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 Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { getSampleFilePath } from "../../sample-files";
import type { ElementAppPage } from "../../pages/ElementAppPage";
const ROOM_NAME = "Test room";
const NAME = "Alice";
async function uploadFile(page: Page, sampleFile: string) {
async function uploadFile(app: ElementAppPage, sampleFile: string) {
// Upload a file from the message composer
await page.locator(".mx_MessageComposer_actions input[type='file']").setInputFiles(getSampleFilePath(sampleFile));
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
await app.composerUploadFiles("room", getSampleFilePath(sampleFile));
// Wait until the file is sent
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(page.locator(".mx_EventTile.mx_EventTile_last").getByRole("status")).toHaveAccessibleName(
await expect(app.page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(app.page.locator(".mx_EventTile.mx_EventTile_last").getByRole("status")).toHaveAccessibleName(
"Your message was sent",
);
}
@ -52,11 +47,11 @@ test.describe("FilePanel", () => {
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png");
});
test("should list tiles on the panel", { tag: "@screenshot" }, async ({ page }) => {
test("should list tiles on the panel", { tag: "@screenshot" }, async ({ page, app }) => {
// Upload multiple files
await uploadFile(page, "riot.png"); // Image
await uploadFile(page, "1sec.ogg"); // Audio
await uploadFile(page, "matrix-org-client-versions.json"); // JSON
await uploadFile(app, "riot.png"); // Image
await uploadFile(app, "1sec.ogg"); // Audio
await uploadFile(app, "matrix-org-client-versions.json"); // JSON
const roomViewBody = page.locator(".mx_RoomView_body");
// Assert that all of the file were uploaded and rendered
@ -136,9 +131,9 @@ test.describe("FilePanel", () => {
});
});
test("should render the audio player and play the audio file on the panel", async ({ page }) => {
test("should render the audio player and play the audio file on the panel", async ({ page, app }) => {
// Upload an image file
await uploadFile(page, "1sec.ogg");
await uploadFile(app, "1sec.ogg");
const audioBody = page.getByTestId("right-panel").getByRole("region", { name: "Audio player" });
@ -167,11 +162,11 @@ test.describe("FilePanel", () => {
await expect(audioBody.getByRole("button", { name: "Play" })).toBeVisible();
});
test("should render file size in kibibytes on a file tile", async ({ page }) => {
test("should render file size in kibibytes on a file tile", async ({ page, app }) => {
const size = "1.12 KB"; // actual file size in kibibytes (1024 bytes)
// Upload a file
await uploadFile(page, "matrix-org-client-versions.json");
await uploadFile(app, "matrix-org-client-versions.json");
const tile = page.locator(".mx_FilePanel .mx_EventTile");
// Assert that the file size is displayed in kibibytes, not kilobytes (1000 bytes)
@ -183,9 +178,9 @@ test.describe("FilePanel", () => {
test.describe("download", () => {
test.skip(isDendrite, "due to a Dendrite sending Content-Disposition inline");
test("should download an image via the link on the panel", async ({ page, context }) => {
test("should download an image via the link on the panel", async ({ page, app, context }) => {
// Upload an image file
await uploadFile(page, "riot.png");
await uploadFile(app, "riot.png");
// Detect the image file on the panel
const imageBody = page.locator(

View File

@ -9,6 +9,7 @@ import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { getSampleFilePath } from "../../sample-files";
test.describe("Threads", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3489");
@ -360,6 +361,50 @@ test.describe("Threads", () => {
await app.getComposer(true).getByRole("button", { name: "Send voice message" }).click();
await expect(page.locator(".mx_ThreadView .mx_MVoiceMessageBody")).toHaveCount(1);
});
test("can send files", async ({ page, app, user }) => {
// Increase right-panel size, so that files fit
await page.evaluate(() => {
window.localStorage.setItem("mx_rhs_size", "600");
});
const roomId = await app.client.createRoom({});
await page.goto("/#/room/" + roomId);
// Send message
const locator = page.locator(".mx_RoomView_body");
await locator.getByRole("textbox", { name: "Send an unencrypted message…" }).fill("Hello Mr. Bot");
await locator.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter");
// Create thread
const locator2 = locator.locator(".mx_EventTile[data-scroll-tokens]").filter({ hasText: "Hello Mr. Bot" });
await locator2.hover();
await locator2.getByRole("button", { name: "Reply in thread" }).click();
await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1);
await app.composerUploadFiles("thread", getSampleFilePath("riot.png"));
await expect(page.locator(".mx_ThreadView .mx_EventTile_image")).toHaveCount(1);
});
test("can send files via drag&drop", async ({ page, app, user }) => {
// Increase right-panel size, so that files fit
await page.evaluate(() => {
window.localStorage.setItem("mx_rhs_size", "600");
});
const roomId = await app.client.createRoom({});
await page.goto("/#/room/" + roomId);
// Send message
const locator = page.locator(".mx_RoomView_body");
await locator.getByRole("textbox", { name: "Send an unencrypted message…" }).fill("Hello Mr. Bot");
await locator.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter");
// Create thread
const locator2 = locator.locator(".mx_EventTile[data-scroll-tokens]").filter({ hasText: "Hello Mr. Bot" });
await locator2.hover();
await locator2.getByRole("button", { name: "Reply in thread" }).click();
await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1);
await app.composerDragAndUploadFiles("thread", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_ThreadView .mx_EventTile_image")).toHaveCount(1);
});
});
test(

View File

@ -7,6 +7,8 @@ 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";
@ -156,6 +158,66 @@ export class ElementAppPage {
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();
}
/**
* Returns the space panel space button based on a name. The space
* must be visible in the space panel