diff --git a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts index 7aac1a6fd9..8223996a61 100644 --- a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts +++ b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts @@ -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"); diff --git a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts index 45ce44df5c..67ca01bd09 100644 --- a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts +++ b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts @@ -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(); + }); }); diff --git a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts index 2fcb6d43ed..e89c10b20f 100644 --- a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts +++ b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts @@ -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( diff --git a/apps/web/playwright/e2e/threads/threads.spec.ts b/apps/web/playwright/e2e/threads/threads.spec.ts index 16a2ead379..a454031933 100644 --- a/apps/web/playwright/e2e/threads/threads.spec.ts +++ b/apps/web/playwright/e2e/threads/threads.spec.ts @@ -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( diff --git a/apps/web/playwright/pages/ElementAppPage.ts b/apps/web/playwright/pages/ElementAppPage.ts index a0add8c83d..ac9212fd10 100644 --- a/apps/web/playwright/pages/ElementAppPage.ts +++ b/apps/web/playwright/pages/ElementAppPage.ts @@ -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 + ): ReturnType { + 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 + ): Promise { + 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 { + // 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