Fix flaky playwright tests (#28957)
* Docs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Avoid reusing user1234 Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix stale-screenshot-reporter.ts Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Clean up public rooms between tests on reused homeserver Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Deflake spotlight when homeserver is reused Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Deflake more tests using existing username Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Clean mailhog between tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix more flakes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix missing _request Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix playwright flaky tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Wipe mailhog between test runs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Deflake more tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix flaky tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix flaky tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix mas config Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix another flaky test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
committed by
GitHub
parent
37f8d70d89
commit
5882b004f5
@@ -9,24 +9,24 @@ import { APIRequestContext } from "playwright-core";
|
|||||||
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
import { HomeserverInstance } from "../plugins/homeserver";
|
import { HomeserverInstance } from "../plugins/homeserver";
|
||||||
|
import { ClientServerApi } from "../plugins/utils/api.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A small subset of the Client-Server API used to manipulate the state of the
|
* A small subset of the Client-Server API used to manipulate the state of the
|
||||||
* account on the homeserver independently of the client under test.
|
* account on the homeserver independently of the client under test.
|
||||||
*/
|
*/
|
||||||
export class TestClientServerAPI {
|
export class TestClientServerAPI extends ClientServerApi {
|
||||||
public constructor(
|
public constructor(
|
||||||
private request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
private homeserver: HomeserverInstance,
|
homeserver: HomeserverInstance,
|
||||||
private accessToken: string,
|
private accessToken: string,
|
||||||
) {}
|
) {
|
||||||
|
super(homeserver.baseUrl);
|
||||||
|
this.setRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
public async getCurrentBackupInfo(): Promise<KeyBackupInfo | null> {
|
public async getCurrentBackupInfo(): Promise<KeyBackupInfo | null> {
|
||||||
const res = await this.request.get(`${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version`, {
|
return this.request("GET", `/v3/room_keys/version`, this.accessToken);
|
||||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
return await res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,15 +34,6 @@ export class TestClientServerAPI {
|
|||||||
* @param version The version to delete
|
* @param version The version to delete
|
||||||
*/
|
*/
|
||||||
public async deleteBackupVersion(version: string): Promise<void> {
|
public async deleteBackupVersion(version: string): Promise<void> {
|
||||||
const res = await this.request.delete(
|
await this.request("DELETE", `/v3/room_keys/version/${version}`, this.accessToken);
|
||||||
`${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version/${version}`,
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to delete backup version: ${res.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AccountDataEvents } from "matrix-js-sdk/src/matrix";
|
import type { AccountDataEvents, Visibility } from "matrix-js-sdk/src/matrix";
|
||||||
import { test, expect } from "../../element-web-test";
|
import { test as base, expect } from "../../element-web-test";
|
||||||
import { Filter } from "../../pages/Spotlight";
|
import { Filter } from "../../pages/Spotlight";
|
||||||
import { Bot } from "../../pages/bot";
|
import { Bot } from "../../pages/bot";
|
||||||
import type { Locator, Page } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
@@ -38,41 +38,37 @@ async function startDM(app: ElementAppPage, page: Page, name: string): Promise<v
|
|||||||
await expect(page.getByRole("group", { name: "People" }).getByText(name)).toBeAttached();
|
await expect(page.getByRole("group", { name: "People" }).getByText(name)).toBeAttached();
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Spotlight", () => {
|
type RoomRef = { name: string; roomId: string };
|
||||||
const bot1Name = "BotBob";
|
const test = base.extend<{
|
||||||
let bot1: Bot;
|
bot1: Bot;
|
||||||
|
bot2: Bot;
|
||||||
const bot2Name = "ByteBot";
|
room1: RoomRef;
|
||||||
let bot2: Bot;
|
room2: RoomRef;
|
||||||
|
room3: RoomRef;
|
||||||
const room1Name = "247";
|
}>({
|
||||||
let room1Id: string;
|
bot1: async ({ page, homeserver }, use, testInfo) => {
|
||||||
|
const bot = new Bot(page, homeserver, { displayName: `BotBob_${testInfo.testId}`, autoAcceptInvites: true });
|
||||||
const room2Name = "Lounge";
|
await use(bot);
|
||||||
let room2Id: string;
|
},
|
||||||
|
bot2: async ({ page, homeserver }, use, testInfo) => {
|
||||||
const room3Name = "Public";
|
const bot = new Bot(page, homeserver, { displayName: `ByteBot_${testInfo.testId}`, autoAcceptInvites: true });
|
||||||
let room3Id: string;
|
await use(bot);
|
||||||
|
},
|
||||||
test.use({
|
room1: async ({ app }, use) => {
|
||||||
displayName: "Jim",
|
const name = "247";
|
||||||
});
|
const roomId = await app.client.createRoom({ name, visibility: "public" as Visibility });
|
||||||
|
await use({ name, roomId });
|
||||||
test.beforeEach(async ({ page, homeserver, app, user }) => {
|
},
|
||||||
bot1 = new Bot(page, homeserver, { displayName: bot1Name, autoAcceptInvites: true });
|
room2: async ({ bot2 }, use) => {
|
||||||
bot2 = new Bot(page, homeserver, { displayName: bot2Name, autoAcceptInvites: true });
|
const name = "Lounge";
|
||||||
const Visibility = await page.evaluate(() => (window as any).matrixcs.Visibility);
|
const roomId = await bot2.createRoom({ name, visibility: "public" as Visibility });
|
||||||
|
await use({ name, roomId });
|
||||||
room1Id = await app.client.createRoom({ name: room1Name, visibility: Visibility.Public });
|
},
|
||||||
|
room3: async ({ bot2 }, use) => {
|
||||||
await bot1.joinRoom(room1Id);
|
const name = "Public";
|
||||||
const bot1UserId = await bot1.evaluate((client) => client.getUserId());
|
const roomId = await bot2.createRoom({
|
||||||
room2Id = await bot2.createRoom({ name: room2Name, visibility: Visibility.Public });
|
name,
|
||||||
await bot2.inviteUser(room2Id, bot1UserId);
|
visibility: "public" as Visibility,
|
||||||
|
|
||||||
room3Id = await bot2.createRoom({
|
|
||||||
name: room3Name,
|
|
||||||
visibility: Visibility.Public,
|
|
||||||
initial_state: [
|
initial_state: [
|
||||||
{
|
{
|
||||||
type: "m.room.history_visibility",
|
type: "m.room.history_visibility",
|
||||||
@@ -83,9 +79,26 @@ test.describe("Spotlight", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await bot2.inviteUser(room3Id, bot1UserId);
|
await use({ name, roomId });
|
||||||
|
},
|
||||||
|
context: async ({ context, homeserver }, use) => {
|
||||||
|
// Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts
|
||||||
|
await homeserver.restart();
|
||||||
|
await use(context);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/#/room/" + room1Id);
|
test.describe("Spotlight", () => {
|
||||||
|
test.use({
|
||||||
|
displayName: "Jim",
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, user, bot1, bot2, room1, room2, room3 }) => {
|
||||||
|
await bot1.joinRoom(room1.roomId);
|
||||||
|
await bot2.inviteUser(room2.roomId, bot1.credentials.userId);
|
||||||
|
await bot2.inviteUser(room3.roomId, bot1.credentials.userId);
|
||||||
|
|
||||||
|
await page.goto(`/#/room/${room1.roomId}`);
|
||||||
await expect(page.locator(".mx_RoomSublist_skeletonUI")).not.toBeAttached();
|
await expect(page.locator(".mx_RoomSublist_skeletonUI")).not.toBeAttached();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,69 +130,69 @@ test.describe("Spotlight", () => {
|
|||||||
await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).not.toBeAttached();
|
await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).not.toBeAttached();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should find joined rooms", async ({ page, app }) => {
|
test("should find joined rooms", async ({ page, app, room1 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.search(room1Name);
|
await spotlight.search(room1.name);
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(room1Name);
|
await expect(resultLocator.first()).toContainText(room1.name);
|
||||||
await resultLocator.first().click();
|
await resultLocator.first().click();
|
||||||
await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`));
|
await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`));
|
||||||
await expect(roomHeaderName(page)).toContainText(room1Name);
|
await expect(roomHeaderName(page)).toContainText(room1.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should find known public rooms", async ({ page, app }) => {
|
test("should find known public rooms", async ({ page, app, room1 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.PublicRooms);
|
await spotlight.filter(Filter.PublicRooms);
|
||||||
await spotlight.search(room1Name);
|
await spotlight.search(room1.name);
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(room1Name);
|
await expect(resultLocator.first()).toContainText(room1.name);
|
||||||
await expect(resultLocator.first()).toContainText("View");
|
await expect(resultLocator.first()).toContainText("View");
|
||||||
await resultLocator.first().click();
|
await resultLocator.first().click();
|
||||||
await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`));
|
await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`));
|
||||||
await expect(roomHeaderName(page)).toContainText(room1Name);
|
await expect(roomHeaderName(page)).toContainText(room1.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should find unknown public rooms", async ({ page, app }) => {
|
test("should find unknown public rooms", async ({ page, app, room2 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.PublicRooms);
|
await spotlight.filter(Filter.PublicRooms);
|
||||||
await spotlight.search(room2Name);
|
await spotlight.search(room2.name);
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(room2Name);
|
await expect(resultLocator.first()).toContainText(room2.name);
|
||||||
await expect(resultLocator.first()).toContainText("Join");
|
await expect(resultLocator.first()).toContainText("Join");
|
||||||
await resultLocator.first().click();
|
await resultLocator.first().click();
|
||||||
await expect(page).toHaveURL(new RegExp(`#/room/${room2Id}`));
|
await expect(page).toHaveURL(new RegExp(`#/room/${room2.roomId}`));
|
||||||
await expect(page.locator(".mx_RoomView_MessageList")).toHaveCount(1);
|
await expect(page.locator(".mx_RoomView_MessageList")).toHaveCount(1);
|
||||||
await expect(roomHeaderName(page)).toContainText(room2Name);
|
await expect(roomHeaderName(page)).toContainText(room2.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should find unknown public world readable rooms", async ({ page, app }) => {
|
test("should find unknown public world readable rooms", async ({ page, app, room3 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.PublicRooms);
|
await spotlight.filter(Filter.PublicRooms);
|
||||||
await spotlight.search(room3Name);
|
await spotlight.search(room3.name);
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(room3Name);
|
await expect(resultLocator.first()).toContainText(room3.name);
|
||||||
await expect(resultLocator.first()).toContainText("View");
|
await expect(resultLocator.first()).toContainText("View");
|
||||||
await resultLocator.first().click();
|
await resultLocator.first().click();
|
||||||
await expect(page).toHaveURL(new RegExp(`#/room/${room3Id}`));
|
await expect(page).toHaveURL(new RegExp(`#/room/${room3.roomId}`));
|
||||||
await page.getByRole("button", { name: "Join the discussion" }).click();
|
await page.getByRole("button", { name: "Join the discussion" }).click();
|
||||||
await expect(roomHeaderName(page)).toHaveText(room3Name);
|
await expect(roomHeaderName(page)).toHaveText(room3.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: We currently can’t test finding rooms on other homeservers/other protocols
|
// TODO: We currently can’t test finding rooms on other homeservers/other protocols
|
||||||
// We obviously don’t have federation or bridges in local e2e tests
|
// We obviously don’t have federation or bridges in local e2e tests
|
||||||
test.skip("should find unknown public rooms on other homeservers", async ({ page, app }) => {
|
test.skip("should find unknown public rooms on other homeservers", async ({ page, app, room3 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.PublicRooms);
|
await spotlight.filter(Filter.PublicRooms);
|
||||||
await spotlight.search(room3Name);
|
await spotlight.search(room3.name);
|
||||||
await page.locator("[aria-haspopup=true][role=button]").click();
|
await page.locator("[aria-haspopup=true][role=button]").click();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
@@ -194,20 +207,20 @@ test.describe("Spotlight", () => {
|
|||||||
|
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(room3Name);
|
await expect(resultLocator.first()).toContainText(room3.name);
|
||||||
await expect(resultLocator.first()).toContainText(room3Id);
|
await expect(resultLocator.first()).toContainText(room3.roomId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should find known people", async ({ page, app }) => {
|
test("should find known people", async ({ page, app, bot1 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
await spotlight.search(bot1Name);
|
await spotlight.search(bot1.credentials.displayName);
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(bot1Name);
|
await expect(resultLocator.first()).toContainText(bot1.credentials.displayName);
|
||||||
await resultLocator.first().click();
|
await resultLocator.first().click();
|
||||||
await expect(roomHeaderName(page)).toHaveText(bot1Name);
|
await expect(roomHeaderName(page)).toHaveText(bot1.credentials.displayName);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,42 +230,41 @@ test.describe("Spotlight", () => {
|
|||||||
*
|
*
|
||||||
* https://github.com/matrix-org/synapse/issues/16472
|
* https://github.com/matrix-org/synapse/issues/16472
|
||||||
*/
|
*/
|
||||||
test("should find unknown people", async ({ page, app }) => {
|
test("should find unknown people", async ({ page, app, bot2 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
await spotlight.search(bot2Name);
|
await spotlight.search(bot2.credentials.displayName);
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(bot2Name);
|
await expect(resultLocator.first()).toContainText(bot2.credentials.displayName);
|
||||||
await resultLocator.first().click();
|
await resultLocator.first().click();
|
||||||
await expect(roomHeaderName(page)).toHaveText(bot2Name);
|
await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should find group DMs by usernames or user ids", async ({ page, app }) => {
|
test("should find group DMs by usernames or user ids", async ({ page, app, bot1, bot2, room1 }) => {
|
||||||
// First we want to share a room with both bots to ensure we’ve got their usernames cached
|
// First we want to share a room with both bots to ensure we’ve got their usernames cached
|
||||||
const bot2UserId = await bot2.evaluate((client) => client.getUserId());
|
await app.client.inviteUser(room1.roomId, bot2.credentials.userId);
|
||||||
await app.client.inviteUser(room1Id, bot2UserId);
|
|
||||||
|
|
||||||
// Starting a DM with ByteBot (will be turned into a group dm later)
|
// Starting a DM with ByteBot (will be turned into a group dm later)
|
||||||
let spotlight = await app.openSpotlight();
|
let spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
await spotlight.search(bot2Name);
|
await spotlight.search(bot2.credentials.displayName);
|
||||||
let resultLocator = spotlight.results;
|
let resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(bot2Name);
|
await expect(resultLocator.first()).toContainText(bot2.credentials.displayName);
|
||||||
await resultLocator.first().click();
|
await resultLocator.first().click();
|
||||||
|
|
||||||
// Send first message to actually start DM
|
// Send first message to actually start DM
|
||||||
await expect(roomHeaderName(page)).toHaveText(bot2Name);
|
await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName);
|
||||||
const locator = page.getByRole("textbox", { name: "Send a message…" });
|
const locator = page.getByRole("textbox", { name: "Send a message…" });
|
||||||
await locator.fill("Hey!");
|
await locator.fill("Hey!");
|
||||||
await locator.press("Enter");
|
await locator.press("Enter");
|
||||||
|
|
||||||
// Assert DM exists by checking for the first message and the room being in the room list
|
// Assert DM exists by checking for the first message and the room being in the room list
|
||||||
await expect(page.locator(".mx_EventTile_body").filter({ hasText: "Hey!" })).toBeAttached({ timeout: 3000 });
|
await expect(page.locator(".mx_EventTile_body").filter({ hasText: "Hey!" })).toBeAttached({ timeout: 3000 });
|
||||||
await expect(page.getByRole("group", { name: "People" })).toContainText(bot2Name);
|
await expect(page.getByRole("group", { name: "People" })).toContainText(bot2.credentials.displayName);
|
||||||
|
|
||||||
// Invite BotBob into existing DM with ByteBot
|
// Invite BotBob into existing DM with ByteBot
|
||||||
const dmRooms = await app.client.evaluate((client, userId) => {
|
const dmRooms = await app.client.evaluate((client, userId) => {
|
||||||
@@ -260,18 +272,17 @@ test.describe("Spotlight", () => {
|
|||||||
.getAccountData("m.direct" as keyof AccountDataEvents)
|
.getAccountData("m.direct" as keyof AccountDataEvents)
|
||||||
?.getContent<Record<string, string[]>>();
|
?.getContent<Record<string, string[]>>();
|
||||||
return map[userId] ?? [];
|
return map[userId] ?? [];
|
||||||
}, bot2UserId);
|
}, bot2.credentials.userId);
|
||||||
expect(dmRooms).toHaveLength(1);
|
expect(dmRooms).toHaveLength(1);
|
||||||
const groupDmName = await app.client.evaluate((client, id) => client.getRoom(id).name, dmRooms[0]);
|
const groupDmName = await app.client.evaluate((client, id) => client.getRoom(id).name, dmRooms[0]);
|
||||||
const bot1UserId = await bot1.evaluate((client) => client.getUserId());
|
await app.client.inviteUser(dmRooms[0], bot1.credentials.userId);
|
||||||
await app.client.inviteUser(dmRooms[0], bot1UserId);
|
|
||||||
await expect(roomHeaderName(page).first()).toContainText(groupDmName);
|
await expect(roomHeaderName(page).first()).toContainText(groupDmName);
|
||||||
await expect(page.getByRole("group", { name: "People" }).first()).toContainText(groupDmName);
|
await expect(page.getByRole("group", { name: "People" }).first()).toContainText(groupDmName);
|
||||||
|
|
||||||
// Search for BotBob by id, should return group DM and user
|
// Search for BotBob by id, should return group DM and user
|
||||||
spotlight = await app.openSpotlight();
|
spotlight = await app.openSpotlight();
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
await spotlight.search(bot1UserId);
|
await spotlight.search(bot1.credentials.userId);
|
||||||
await page.waitForTimeout(1000); // wait for the dialog to settle
|
await page.waitForTimeout(1000); // wait for the dialog to settle
|
||||||
resultLocator = spotlight.results;
|
resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(2);
|
await expect(resultLocator).toHaveCount(2);
|
||||||
@@ -284,7 +295,7 @@ test.describe("Spotlight", () => {
|
|||||||
// Search for ByteBot by id, should return group DM and user
|
// Search for ByteBot by id, should return group DM and user
|
||||||
spotlight = await app.openSpotlight();
|
spotlight = await app.openSpotlight();
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
await spotlight.search(bot2UserId);
|
await spotlight.search(bot2.credentials.userId);
|
||||||
await page.waitForTimeout(1000); // wait for the dialog to settle
|
await page.waitForTimeout(1000); // wait for the dialog to settle
|
||||||
resultLocator = spotlight.results;
|
resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(2);
|
await expect(resultLocator).toHaveCount(2);
|
||||||
@@ -297,11 +308,10 @@ test.describe("Spotlight", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test against https://github.com/vector-im/element-web/issues/22851
|
// Test against https://github.com/vector-im/element-web/issues/22851
|
||||||
test("should show each person result only once", async ({ page, app }) => {
|
test("should show each person result only once", async ({ page, app, bot1 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
const bot1UserId = await bot1.evaluate((client) => client.getUserId());
|
|
||||||
|
|
||||||
// 2 rounds of search to simulate the bug conditions. Specifically, the first search
|
// 2 rounds of search to simulate the bug conditions. Specifically, the first search
|
||||||
// should have 1 result (not 2) and the second search should also have 1 result (instead
|
// should have 1 result (not 2) and the second search should also have 1 result (instead
|
||||||
@@ -310,24 +320,24 @@ test.describe("Spotlight", () => {
|
|||||||
// We search for user ID to trigger the profile lookup within the dialog.
|
// We search for user ID to trigger the profile lookup within the dialog.
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
console.log("Iteration: " + i);
|
console.log("Iteration: " + i);
|
||||||
await spotlight.search(bot1UserId);
|
await spotlight.search(bot1.credentials.userId);
|
||||||
await page.waitForTimeout(1000); // wait for the dialog to settle
|
await page.waitForTimeout(1000); // wait for the dialog to settle
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(bot1UserId);
|
await expect(resultLocator.first()).toContainText(bot1.credentials.userId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should allow opening group chat dialog", async ({ page, app }) => {
|
test("should allow opening group chat dialog", async ({ page, app, bot2 }) => {
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
await spotlight.search(bot2Name);
|
await spotlight.search(bot2.credentials.displayName);
|
||||||
await page.waitForTimeout(3000); // wait for the dialog to settle
|
await page.waitForTimeout(3000); // wait for the dialog to settle
|
||||||
|
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
await expect(resultLocator).toHaveCount(1);
|
await expect(resultLocator).toHaveCount(1);
|
||||||
await expect(resultLocator.first()).toContainText(bot2Name);
|
await expect(resultLocator.first()).toContainText(bot2.credentials.displayName);
|
||||||
|
|
||||||
await expect(spotlight.dialog.locator(".mx_SpotlightDialog_startGroupChat")).toContainText(
|
await expect(spotlight.dialog.locator(".mx_SpotlightDialog_startGroupChat")).toContainText(
|
||||||
"Start a group chat",
|
"Start a group chat",
|
||||||
@@ -336,18 +346,18 @@ test.describe("Spotlight", () => {
|
|||||||
await expect(page.getByRole("dialog")).toContainText("Direct Messages");
|
await expect(page.getByRole("dialog")).toContainText("Direct Messages");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should close spotlight after starting a DM", async ({ page, app }) => {
|
test("should close spotlight after starting a DM", async ({ page, app, bot1 }) => {
|
||||||
await startDM(app, page, bot1Name);
|
await startDM(app, page, bot1.credentials.displayName);
|
||||||
await expect(page.locator(".mx_SpotlightDialog")).toHaveCount(0);
|
await expect(page.locator(".mx_SpotlightDialog")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show the same user only once", async ({ page, app }) => {
|
test("should show the same user only once", async ({ page, app, bot1 }) => {
|
||||||
await startDM(app, page, bot1Name);
|
await startDM(app, page, bot1.credentials.displayName);
|
||||||
await page.goto("/#/home");
|
await page.goto("/#/home");
|
||||||
const spotlight = await app.openSpotlight();
|
const spotlight = await app.openSpotlight();
|
||||||
await page.waitForTimeout(500); // wait for the dialog to settle
|
await page.waitForTimeout(500); // wait for the dialog to settle
|
||||||
await spotlight.filter(Filter.People);
|
await spotlight.filter(Filter.People);
|
||||||
await spotlight.search(bot1Name);
|
await spotlight.search(bot1.credentials.displayName);
|
||||||
await page.waitForTimeout(3000); // wait for the dialog to settle
|
await page.waitForTimeout(3000); // wait for the dialog to settle
|
||||||
await expect(spotlight.dialog.locator(".mx_Spinner")).not.toBeAttached();
|
await expect(spotlight.dialog.locator(".mx_Spinner")).not.toBeAttached();
|
||||||
const resultLocator = spotlight.results;
|
const resultLocator = spotlight.results;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const CONFIG_JSON: Partial<IConfigOptions> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CredentialsWithDisplayName extends Credentials {
|
export interface CredentialsWithDisplayName extends Credentials {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ClientServerApi } from "../utils/api.ts";
|
||||||
|
|
||||||
export interface HomeserverInstance {
|
export interface HomeserverInstance {
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
|
readonly csApi: ClientServerApi;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a user on the given Homeserver using the shared registration secret.
|
* Register a user on the given Homeserver using the shared registration secret.
|
||||||
|
|||||||
76
playwright/plugins/utils/api.ts
Normal file
76
playwright/plugins/utils/api.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
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 { APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
|
import { Credentials } from "../homeserver";
|
||||||
|
|
||||||
|
export type Verb = "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
|
||||||
|
export class Api {
|
||||||
|
private _request?: APIRequestContext;
|
||||||
|
|
||||||
|
constructor(private readonly baseUrl: string) {}
|
||||||
|
|
||||||
|
public setRequest(request: APIRequestContext): void {
|
||||||
|
this._request = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async request<R extends {}>(verb: "GET", path: string, token?: string, data?: never): Promise<R>;
|
||||||
|
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R>;
|
||||||
|
public async request<R extends {}>(verb: Verb, path: string, token?: string, data?: object): Promise<R> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
const res = await this._request.fetch(url, {
|
||||||
|
data,
|
||||||
|
method: verb,
|
||||||
|
headers: token
|
||||||
|
? {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok()) {
|
||||||
|
throw new Error(
|
||||||
|
`Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientServerApi extends Api {
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
super(`${baseUrl}/_matrix/client`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loginUser(userId: string, password: string): Promise<Credentials> {
|
||||||
|
const json = await this.request<{
|
||||||
|
access_token: string;
|
||||||
|
user_id: string;
|
||||||
|
device_id: string;
|
||||||
|
home_server: string;
|
||||||
|
}>("POST", "/v3/login", undefined, {
|
||||||
|
type: "m.login.password",
|
||||||
|
identifier: {
|
||||||
|
type: "m.id.user",
|
||||||
|
user: userId,
|
||||||
|
},
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
password,
|
||||||
|
accessToken: json.access_token,
|
||||||
|
userId: json.user_id,
|
||||||
|
deviceId: json.device_id,
|
||||||
|
homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"),
|
||||||
|
username: userId.slice(1).split(":")[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ export const test = base.extend<TestFixtures, Services & Options>({
|
|||||||
.withNetworkAliases("homeserver")
|
.withNetworkAliases("homeserver")
|
||||||
.withLogConsumer(logger.getConsumer("synapse"))
|
.withLogConsumer(logger.getConsumer("synapse"))
|
||||||
.withConfig(synapseConfig)
|
.withConfig(synapseConfig)
|
||||||
|
.withMatrixAuthenticationService(mas)
|
||||||
.start();
|
.start();
|
||||||
|
|
||||||
await use(container);
|
await use(container);
|
||||||
@@ -141,5 +142,6 @@ export const test = base.extend<TestFixtures, Services & Options>({
|
|||||||
await logger.onTestStarted(context);
|
await logger.onTestStarted(context);
|
||||||
await use(context);
|
await use(context);
|
||||||
await logger.onTestFinished(testInfo);
|
await logger.onTestFinished(testInfo);
|
||||||
|
await homeserver.onTestFinished(testInfo);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,17 +6,19 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { AbstractStartedContainer, GenericContainer } from "testcontainers";
|
import { AbstractStartedContainer, GenericContainer } from "testcontainers";
|
||||||
import { APIRequestContext } from "@playwright/test";
|
import { APIRequestContext, TestInfo } from "@playwright/test";
|
||||||
|
|
||||||
import { StartedSynapseContainer } from "./synapse.ts";
|
|
||||||
import { HomeserverInstance } from "../plugins/homeserver";
|
import { HomeserverInstance } from "../plugins/homeserver";
|
||||||
|
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||||
|
|
||||||
export interface HomeserverContainer<Config> extends GenericContainer {
|
export interface HomeserverContainer<Config> extends GenericContainer {
|
||||||
withConfigField(key: string, value: any): this;
|
withConfigField(key: string, value: any): this;
|
||||||
withConfig(config: Partial<Config>): this;
|
withConfig(config: Partial<Config>): this;
|
||||||
start(): Promise<StartedSynapseContainer>;
|
withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this;
|
||||||
|
start(): Promise<StartedHomeserverContainer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {
|
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {
|
||||||
setRequest(request: APIRequestContext): void;
|
setRequest(request: APIRequestContext): void;
|
||||||
|
onTestFinished(testInfo: TestInfo): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { randB64Bytes } from "../plugins/utils/rand.ts";
|
|||||||
import { StartedSynapseContainer } from "./synapse.ts";
|
import { StartedSynapseContainer } from "./synapse.ts";
|
||||||
import { deepCopy } from "../plugins/utils/object.ts";
|
import { deepCopy } from "../plugins/utils/object.ts";
|
||||||
import { HomeserverContainer } from "./HomeserverContainer.ts";
|
import { HomeserverContainer } from "./HomeserverContainer.ts";
|
||||||
|
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
version: 2,
|
version: 2,
|
||||||
@@ -235,7 +236,11 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async start(): Promise<StartedSynapseContainer> {
|
public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
|
||||||
|
throw new Error("Dendrite does not support MAS.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async start(): Promise<StartedDendriteContainer> {
|
||||||
this.withCopyContentToContainer([
|
this.withCopyContentToContainer([
|
||||||
{
|
{
|
||||||
target: "/etc/dendrite/dendrite.yaml",
|
target: "/etc/dendrite/dendrite.yaml",
|
||||||
@@ -244,8 +249,7 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const container = await super.start();
|
const container = await super.start();
|
||||||
// Surprisingly, Dendrite implements the same register user Admin API Synapse, so we can just extend it
|
return new StartedDendriteContainer(
|
||||||
return new StartedSynapseContainer(
|
|
||||||
container,
|
container,
|
||||||
`http://${container.getHost()}:${container.getMappedPort(8008)}`,
|
`http://${container.getHost()}:${container.getMappedPort(8008)}`,
|
||||||
this.config.client_api.registration_shared_secret,
|
this.config.client_api.registration_shared_secret,
|
||||||
@@ -258,3 +262,6 @@ export class PineconeContainer extends DendriteContainer {
|
|||||||
super("matrixdotorg/dendrite-demo-pinecone:main", "/usr/bin/dendrite-demo-pinecone");
|
super("matrixdotorg/dendrite-demo-pinecone:main", "/usr/bin/dendrite-demo-pinecone");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surprisingly, Dendrite implements the same register user Synapse Admin API, so we can just extend it
|
||||||
|
export class StartedDendriteContainer extends StartedSynapseContainer {}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
|
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait, ExecResult } from "testcontainers";
|
||||||
import { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
import { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
||||||
import * as YAML from "yaml";
|
import * as YAML from "yaml";
|
||||||
|
|
||||||
import { getFreePort } from "../plugins/utils/port.ts";
|
import { getFreePort } from "../plugins/utils/port.ts";
|
||||||
import { deepCopy } from "../plugins/utils/object.ts";
|
import { deepCopy } from "../plugins/utils/object.ts";
|
||||||
|
import { Credentials } from "../plugins/homeserver";
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
http: {
|
http: {
|
||||||
@@ -18,18 +19,10 @@ const DEFAULT_CONFIG = {
|
|||||||
{
|
{
|
||||||
name: "web",
|
name: "web",
|
||||||
resources: [
|
resources: [
|
||||||
{
|
{ name: "discovery" },
|
||||||
name: "discovery",
|
{ name: "human" },
|
||||||
},
|
{ name: "oauth" },
|
||||||
{
|
{ name: "compat" },
|
||||||
name: "human",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "oauth",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "compat",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "graphql",
|
name: "graphql",
|
||||||
playground: true,
|
playground: true,
|
||||||
@@ -182,9 +175,12 @@ const DEFAULT_CONFIG = {
|
|||||||
|
|
||||||
export class MatrixAuthenticationServiceContainer extends GenericContainer {
|
export class MatrixAuthenticationServiceContainer extends GenericContainer {
|
||||||
private config: typeof DEFAULT_CONFIG;
|
private config: typeof DEFAULT_CONFIG;
|
||||||
|
private readonly args = ["-c", "/config/config.yaml"];
|
||||||
|
|
||||||
constructor(db: StartedPostgreSqlContainer) {
|
constructor(db: StartedPostgreSqlContainer) {
|
||||||
super("ghcr.io/element-hq/matrix-authentication-service:0.12.0");
|
// We rely on `mas-cli manage add-email` which isn't in a release yet
|
||||||
|
// https://github.com/element-hq/matrix-authentication-service/pull/3235
|
||||||
|
super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33");
|
||||||
|
|
||||||
this.config = deepCopy(DEFAULT_CONFIG);
|
this.config = deepCopy(DEFAULT_CONFIG);
|
||||||
this.config.database.username = db.getUsername();
|
this.config.database.username = db.getUsername();
|
||||||
@@ -192,7 +188,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer {
|
|||||||
|
|
||||||
this.withExposedPorts(8080, 8081)
|
this.withExposedPorts(8080, 8081)
|
||||||
.withWaitStrategy(Wait.forHttp("/health", 8081))
|
.withWaitStrategy(Wait.forHttp("/health", 8081))
|
||||||
.withCommand(["server", "--config", "/config/config.yaml"]);
|
.withCommand(["server", ...this.args]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public withConfig(config: object): this {
|
public withConfig(config: object): this {
|
||||||
@@ -220,15 +216,125 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new StartedMatrixAuthenticationServiceContainer(await super.start(), `http://localhost:${port}`);
|
return new StartedMatrixAuthenticationServiceContainer(
|
||||||
|
await super.start(),
|
||||||
|
`http://localhost:${port}`,
|
||||||
|
this.args,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer {
|
export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer {
|
||||||
|
private adminTokenPromise?: Promise<string>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
container: StartedTestContainer,
|
container: StartedTestContainer,
|
||||||
public readonly baseUrl: string,
|
public readonly baseUrl: string,
|
||||||
|
private readonly args: string[],
|
||||||
) {
|
) {
|
||||||
super(container);
|
super(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAdminToken(): Promise<string> {
|
||||||
|
if (this.adminTokenPromise === undefined) {
|
||||||
|
this.adminTokenPromise = this.registerUserInternal(
|
||||||
|
"admin",
|
||||||
|
"totalyinsecureadminpassword",
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
).then((res) => res.accessToken);
|
||||||
|
}
|
||||||
|
return this.adminTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async manage(cmd: string, ...args: string[]): Promise<ExecResult> {
|
||||||
|
const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async manageRegisterUser(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
displayName?: string,
|
||||||
|
admin = false,
|
||||||
|
): Promise<string> {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (admin) args.push("-a");
|
||||||
|
const result = await this.manage(
|
||||||
|
"register-user",
|
||||||
|
...args,
|
||||||
|
"-y",
|
||||||
|
"-p",
|
||||||
|
password,
|
||||||
|
"-d",
|
||||||
|
displayName ?? "",
|
||||||
|
username,
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerLines = result.output.trim().split("\n");
|
||||||
|
const userId = registerLines
|
||||||
|
.find((line) => line.includes("Matrix ID: "))
|
||||||
|
?.split(": ")
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error(`Failed to register user: ${result.output}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async manageIssueCompatibilityToken(
|
||||||
|
username: string,
|
||||||
|
admin = false,
|
||||||
|
): Promise<{ accessToken: string; deviceId: string }> {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges");
|
||||||
|
const result = await this.manage("issue-compatibility-token", ...args, username);
|
||||||
|
|
||||||
|
const parts = result.output.trim().split(/\s+/);
|
||||||
|
const accessToken = parts.find((part) => part.startsWith("mct_"));
|
||||||
|
const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1];
|
||||||
|
|
||||||
|
if (!accessToken || !deviceId) {
|
||||||
|
throw new Error(`Failed to issue compatibility token: ${result.output}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accessToken, deviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async registerUserInternal(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
displayName?: string,
|
||||||
|
admin = false,
|
||||||
|
): Promise<Credentials> {
|
||||||
|
const userId = await this.manageRegisterUser(username, password, displayName, admin);
|
||||||
|
const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
accessToken,
|
||||||
|
deviceId,
|
||||||
|
homeServer: userId.slice(1).split(":").slice(1).join(":"),
|
||||||
|
displayName,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
|
||||||
|
return this.registerUserInternal(username, password, displayName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setThreepid(username: string, medium: string, address: string): Promise<void> {
|
||||||
|
if (medium !== "email") {
|
||||||
|
throw new Error("Only email threepids are supported by MAS");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.manage("add-email", username, address);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { AbstractStartedContainer, GenericContainer, RestartOptions, StartedTestContainer, Wait } from "testcontainers";
|
import { AbstractStartedContainer, GenericContainer, RestartOptions, StartedTestContainer, Wait } from "testcontainers";
|
||||||
import { APIRequestContext } from "@playwright/test";
|
import { APIRequestContext, TestInfo } from "@playwright/test";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import * as YAML from "yaml";
|
import * as YAML from "yaml";
|
||||||
import { set } from "lodash";
|
import { set } from "lodash";
|
||||||
@@ -16,6 +16,8 @@ import { randB64Bytes } from "../plugins/utils/rand.ts";
|
|||||||
import { Credentials } from "../plugins/homeserver";
|
import { Credentials } from "../plugins/homeserver";
|
||||||
import { deepCopy } from "../plugins/utils/object.ts";
|
import { deepCopy } from "../plugins/utils/object.ts";
|
||||||
import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.ts";
|
import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.ts";
|
||||||
|
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||||
|
import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts";
|
||||||
|
|
||||||
const TAG = "develop@sha256:7be2e00da62dfbb2bad071c6d408fecb1fabf740a538d08768b9b3e0a8c45350";
|
const TAG = "develop@sha256:7be2e00da62dfbb2bad071c6d408fecb1fabf740a538d08768b9b3e0a8c45350";
|
||||||
|
|
||||||
@@ -142,6 +144,7 @@ export type SynapseConfig = Partial<typeof DEFAULT_CONFIG>;
|
|||||||
|
|
||||||
export class SynapseContainer extends GenericContainer implements HomeserverContainer<typeof DEFAULT_CONFIG> {
|
export class SynapseContainer extends GenericContainer implements HomeserverContainer<typeof DEFAULT_CONFIG> {
|
||||||
private config: typeof DEFAULT_CONFIG;
|
private config: typeof DEFAULT_CONFIG;
|
||||||
|
private mas?: StartedMatrixAuthenticationServiceContainer;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(`ghcr.io/element-hq/synapse:${TAG}`);
|
super(`ghcr.io/element-hq/synapse:${TAG}`);
|
||||||
@@ -201,6 +204,11 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
|
||||||
|
this.mas = mas;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public override async start(): Promise<StartedSynapseContainer> {
|
public override async start(): Promise<StartedSynapseContainer> {
|
||||||
// Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually
|
// Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
@@ -219,17 +227,25 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new StartedSynapseContainer(
|
const container = await super.start();
|
||||||
await super.start(),
|
const baseUrl = `http://localhost:${port}`;
|
||||||
`http://localhost:${port}`,
|
if (this.mas) {
|
||||||
this.config.registration_shared_secret,
|
return new StartedSynapseWithMasContainer(
|
||||||
);
|
container,
|
||||||
|
baseUrl,
|
||||||
|
this.config.registration_shared_secret,
|
||||||
|
this.mas,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
|
export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
|
||||||
private adminToken?: string;
|
protected adminTokenPromise?: Promise<string>;
|
||||||
private request?: APIRequestContext;
|
protected readonly adminApi: Api;
|
||||||
|
public readonly csApi: ClientServerApi;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
container: StartedTestContainer,
|
container: StartedTestContainer,
|
||||||
@@ -237,15 +253,36 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
|||||||
private readonly registrationSharedSecret: string,
|
private readonly registrationSharedSecret: string,
|
||||||
) {
|
) {
|
||||||
super(container);
|
super(container);
|
||||||
|
this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`);
|
||||||
|
this.csApi = new ClientServerApi(this.baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public restart(options?: Partial<RestartOptions>): Promise<void> {
|
public restart(options?: Partial<RestartOptions>): Promise<void> {
|
||||||
this.adminToken = undefined;
|
this.adminTokenPromise = undefined;
|
||||||
return super.restart(options);
|
return super.restart(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setRequest(request: APIRequestContext): void {
|
public setRequest(request: APIRequestContext): void {
|
||||||
this.request = request;
|
this.csApi.setRequest(request);
|
||||||
|
this.adminApi.setRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async onTestFinished(testInfo: TestInfo): Promise<void> {
|
||||||
|
// Clean up the server to prevent rooms leaking between tests
|
||||||
|
await this.deletePublicRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async deletePublicRooms(): Promise<void> {
|
||||||
|
const token = await this.getAdminToken();
|
||||||
|
// We hide the rooms from the room directory to save time between tests and for portability between homeservers
|
||||||
|
const { chunk: rooms } = await this.csApi.request<{
|
||||||
|
chunk: { room_id: string }[];
|
||||||
|
}>("GET", "/v3/publicRooms", token, {});
|
||||||
|
await Promise.all(
|
||||||
|
rooms.map((room) =>
|
||||||
|
this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async registerUserInternal(
|
private async registerUserInternal(
|
||||||
@@ -254,28 +291,26 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
|||||||
displayName?: string,
|
displayName?: string,
|
||||||
admin = false,
|
admin = false,
|
||||||
): Promise<Credentials> {
|
): Promise<Credentials> {
|
||||||
const url = `${this.baseUrl}/_synapse/admin/v1/register`;
|
const path = "/v1/register";
|
||||||
const { nonce } = await this.request.get(url).then((r) => r.json());
|
const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {});
|
||||||
const mac = crypto
|
const mac = crypto
|
||||||
.createHmac("sha1", this.registrationSharedSecret)
|
.createHmac("sha1", this.registrationSharedSecret)
|
||||||
.update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
|
.update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
const res = await this.request.post(url, {
|
const data = await this.adminApi.request<{
|
||||||
data: {
|
home_server: string;
|
||||||
nonce,
|
access_token: string;
|
||||||
username,
|
user_id: string;
|
||||||
password,
|
device_id: string;
|
||||||
mac,
|
}>("POST", path, undefined, {
|
||||||
admin,
|
nonce,
|
||||||
displayname: displayName,
|
username,
|
||||||
},
|
password,
|
||||||
|
mac,
|
||||||
|
admin,
|
||||||
|
displayname: displayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok()) {
|
|
||||||
throw await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return {
|
return {
|
||||||
homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"),
|
homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"),
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
@@ -287,57 +322,67 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async getAdminToken(): Promise<string> {
|
||||||
|
if (this.adminTokenPromise === undefined) {
|
||||||
|
this.adminTokenPromise = this.registerUserInternal(
|
||||||
|
"admin",
|
||||||
|
"totalyinsecureadminpassword",
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
).then((res) => res.accessToken);
|
||||||
|
}
|
||||||
|
return this.adminTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async adminRequest<R extends {}>(verb: "GET", path: string, data?: never): Promise<R>;
|
||||||
|
private async adminRequest<R extends {}>(verb: Verb, path: string, data?: object): Promise<R>;
|
||||||
|
private async adminRequest<R extends {}>(verb: Verb, path: string, data?: object): Promise<R> {
|
||||||
|
const adminToken = await this.getAdminToken();
|
||||||
|
return this.adminApi.request(verb, path, adminToken, data);
|
||||||
|
}
|
||||||
|
|
||||||
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
|
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
|
||||||
return this.registerUserInternal(username, password, displayName, false);
|
return this.registerUserInternal(username, password, displayName, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loginUser(userId: string, password: string): Promise<Credentials> {
|
public async loginUser(userId: string, password: string): Promise<Credentials> {
|
||||||
const url = `${this.baseUrl}/_matrix/client/v3/login`;
|
return this.csApi.loginUser(userId, password);
|
||||||
const res = await this.request.post(url, {
|
|
||||||
data: {
|
|
||||||
type: "m.login.password",
|
|
||||||
identifier: {
|
|
||||||
type: "m.id.user",
|
|
||||||
user: userId,
|
|
||||||
},
|
|
||||||
password: password,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
password,
|
|
||||||
accessToken: json.access_token,
|
|
||||||
userId: json.user_id,
|
|
||||||
deviceId: json.device_id,
|
|
||||||
homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"),
|
|
||||||
username: userId.slice(1).split(":")[0],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
|
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
|
||||||
if (this.adminToken === undefined) {
|
await this.adminRequest("PUT", `/v2/users/${userId}`, {
|
||||||
const result = await this.registerUserInternal("admin", "totalyinsecureadminpassword", undefined, true);
|
threepids: [
|
||||||
this.adminToken = result.accessToken;
|
{
|
||||||
}
|
medium,
|
||||||
|
address,
|
||||||
const url = `${this.baseUrl}/_synapse/admin/v2/users/${userId}`;
|
},
|
||||||
const res = await this.request.put(url, {
|
],
|
||||||
data: {
|
|
||||||
threepids: [
|
|
||||||
{
|
|
||||||
medium,
|
|
||||||
address,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.adminToken}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (!res.ok()) {
|
}
|
||||||
throw await res.json();
|
|
||||||
}
|
export class StartedSynapseWithMasContainer extends StartedSynapseContainer {
|
||||||
|
constructor(
|
||||||
|
container: StartedTestContainer,
|
||||||
|
baseUrl: string,
|
||||||
|
registrationSharedSecret: string,
|
||||||
|
private readonly mas: StartedMatrixAuthenticationServiceContainer,
|
||||||
|
) {
|
||||||
|
super(container, baseUrl, registrationSharedSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getAdminToken(): Promise<string> {
|
||||||
|
if (this.adminTokenPromise === undefined) {
|
||||||
|
this.adminTokenPromise = this.mas.getAdminToken();
|
||||||
|
}
|
||||||
|
return this.adminTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
|
||||||
|
return this.mas.registerUser(username, password, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
|
||||||
|
return this.mas.setThreepid(userId, medium, address);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user