Migrate crypto test to cypress (#8833)
This commit is contained in:
parent
171f5adff6
commit
7e47749ce2
10 changed files with 207 additions and 72 deletions
|
@ -27,7 +27,7 @@ describe("UserView", () => {
|
||||||
synapse = data;
|
synapse = data;
|
||||||
|
|
||||||
cy.initTestUser(synapse, "Violet");
|
cy.initTestUser(synapse, "Violet");
|
||||||
cy.getBot(synapse, "Usman").as("bot");
|
cy.getBot(synapse, { displayName: "Usman" }).as("bot");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ describe("Room Directory", () => {
|
||||||
synapse = data;
|
synapse = data;
|
||||||
|
|
||||||
cy.initTestUser(synapse, "Ray");
|
cy.initTestUser(synapse, "Ray");
|
||||||
cy.getBot(synapse, "Paul").as("bot");
|
cy.getBot(synapse, { displayName: "Paul" }).as("bot");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -128,11 +128,11 @@ describe("Spotlight", () => {
|
||||||
cy.startSynapse("default").then(data => {
|
cy.startSynapse("default").then(data => {
|
||||||
synapse = data;
|
synapse = data;
|
||||||
cy.initTestUser(synapse, "Jim").then(() =>
|
cy.initTestUser(synapse, "Jim").then(() =>
|
||||||
cy.getBot(synapse, bot1Name).then(_bot1 => {
|
cy.getBot(synapse, { displayName: bot1Name }).then(_bot1 => {
|
||||||
bot1 = _bot1;
|
bot1 = _bot1;
|
||||||
}),
|
}),
|
||||||
).then(() =>
|
).then(() =>
|
||||||
cy.getBot(synapse, bot2Name).then(_bot2 => {
|
cy.getBot(synapse, { displayName: bot2Name }).then(_bot2 => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
bot2 = _bot2;
|
bot2 = _bot2;
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -73,7 +73,7 @@ describe("Threads", () => {
|
||||||
|
|
||||||
it("should be usable for a conversation", () => {
|
it("should be usable for a conversation", () => {
|
||||||
let bot: MatrixClient;
|
let bot: MatrixClient;
|
||||||
cy.getBot(synapse, "BotBob").then(_bot => {
|
cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => {
|
||||||
bot = _bot;
|
bot = _bot;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -167,7 +167,7 @@ describe("Spaces", () => {
|
||||||
|
|
||||||
it("should allow user to invite another to a space", () => {
|
it("should allow user to invite another to a space", () => {
|
||||||
let bot: MatrixClient;
|
let bot: MatrixClient;
|
||||||
cy.getBot(synapse, "BotBob").then(_bot => {
|
cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => {
|
||||||
bot = _bot;
|
bot = _bot;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -202,7 +202,7 @@ describe("Spaces", () => {
|
||||||
});
|
});
|
||||||
getSpacePanelButton("My Space").should("exist");
|
getSpacePanelButton("My Space").should("exist");
|
||||||
|
|
||||||
cy.getBot(synapse, "BotBob").then({ timeout: 10000 }, async bot => {
|
cy.getBot(synapse, { displayName: "BotBob" }).then({ timeout: 10000 }, async bot => {
|
||||||
const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space"));
|
const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space"));
|
||||||
await bot.invite(roomId, user.userId);
|
await bot.invite(roomId, user.userId);
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,73 +14,145 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/// <reference types="cypress" />
|
import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||||
|
import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS";
|
||||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||||
import { SynapseInstance } from "../../plugins/synapsedocker";
|
import { SynapseInstance } from "../../plugins/synapsedocker";
|
||||||
|
import Chainable = Cypress.Chainable;
|
||||||
|
|
||||||
function waitForEncryption(cli: MatrixClient, roomId: string, win: Cypress.AUTWindow): Promise<void> {
|
type EmojiMapping = [emoji: string, name: string];
|
||||||
return new Promise<void>(resolve => {
|
interface CryptoTestContext extends Mocha.Context {
|
||||||
const onEvent = () => {
|
synapse: SynapseInstance;
|
||||||
cli.crypto.cryptoStore.getEndToEndRooms(null, (result) => {
|
bob: MatrixClient;
|
||||||
if (result[roomId]) {
|
|
||||||
cli.off(win.matrixcs.ClientEvent.Event, onEvent);
|
|
||||||
resolve();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const waitForVerificationRequest = (cli: MatrixClient): Promise<VerificationRequest> => {
|
||||||
|
return new Promise<VerificationRequest>(resolve => {
|
||||||
|
const onVerificationRequestEvent = (request: VerificationRequest) => {
|
||||||
|
// @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here
|
||||||
|
cli.off("crypto.verification.request", onVerificationRequestEvent);
|
||||||
|
resolve(request);
|
||||||
|
};
|
||||||
|
// @ts-ignore
|
||||||
|
cli.on("crypto.verification.request", onVerificationRequestEvent);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
cli.on(win.matrixcs.ClientEvent.Event, onEvent);
|
|
||||||
|
const openRoomInfo = () => {
|
||||||
|
cy.get(".mx_RightPanel_roomSummaryButton").click();
|
||||||
|
return cy.get(".mx_RightPanel");
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDMRoom = () => {
|
||||||
|
cy.contains(".mx_TextualEvent", "Alice invited Bob").should("exist");
|
||||||
|
cy.contains(".mx_RoomView_body .mx_cryptoEvent", "Encryption enabled").should("exist");
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDMWithBob = function(this: CryptoTestContext) {
|
||||||
|
cy.get('.mx_RoomList [aria-label="Start chat"]').click();
|
||||||
|
cy.get('[data-test-id="invite-dialog-input"]').type(this.bob.getUserId());
|
||||||
|
cy.contains(".mx_InviteDialog_tile_nameStack_name", "Bob").click();
|
||||||
|
cy.contains(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name", "Bob").should("exist");
|
||||||
|
cy.get(".mx_InviteDialog_goButton").click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const testMessages = function(this: CryptoTestContext) {
|
||||||
|
cy.get(".mx_BasicMessageComposer_input").should("have.focus").type("Hey!{enter}");
|
||||||
|
cy.contains(".mx_EventTile_body", "Hey!")
|
||||||
|
.closest(".mx_EventTile")
|
||||||
|
.should("not.have.descendants", ".mx_EventTile_e2eIcon_warning")
|
||||||
|
.should("have.descendants", ".mx_EventTile_receiptSent");
|
||||||
|
|
||||||
|
// Bob sends a response
|
||||||
|
cy.get<Room>("@bobsRoom").then((room) => {
|
||||||
|
this.bob.sendTextMessage(room.roomId, "Hoo!");
|
||||||
});
|
});
|
||||||
}
|
cy.contains(".mx_EventTile_body", "Hoo!")
|
||||||
|
.closest(".mx_EventTile")
|
||||||
describe("Cryptography", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.startSynapse("default").as('synapse').then(
|
|
||||||
synapse => cy.initTestUser(synapse, "Alice"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cy.get<SynapseInstance>('@synapse').then(synapse => cy.stopSynapse(synapse));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should receive and decrypt encrypted messages", () => {
|
|
||||||
cy.get<SynapseInstance>('@synapse').then(synapse => cy.getBot(synapse, "Beatrice").as('bot'));
|
|
||||||
|
|
||||||
cy.createRoom({
|
|
||||||
initial_state: [
|
|
||||||
{
|
|
||||||
type: "m.room.encryption",
|
|
||||||
state_key: '',
|
|
||||||
content: {
|
|
||||||
algorithm: "m.megolm.v1.aes-sha2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).as('roomId');
|
|
||||||
|
|
||||||
cy.all([
|
|
||||||
cy.get<MatrixClient>('@bot'),
|
|
||||||
cy.get<string>('@roomId'),
|
|
||||||
cy.window(),
|
|
||||||
]).then(([bot, roomId, win]) => {
|
|
||||||
cy.inviteUser(roomId, bot.getUserId());
|
|
||||||
cy.wrap(
|
|
||||||
waitForEncryption(
|
|
||||||
bot, roomId, win,
|
|
||||||
).then(() => bot.sendMessage(roomId, {
|
|
||||||
body: "Top secret message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
cy.visit("/#/room/" + roomId);
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.get(".mx_RoomView_body .mx_cryptoEvent").should("contain", "Encryption enabled");
|
|
||||||
|
|
||||||
cy.get(".mx_EventTile_body")
|
|
||||||
.contains("Top secret message")
|
|
||||||
.closest(".mx_EventTile_line")
|
|
||||||
.should("not.have.descendants", ".mx_EventTile_e2eIcon_warning");
|
.should("not.have.descendants", ".mx_EventTile_e2eIcon_warning");
|
||||||
|
};
|
||||||
|
|
||||||
|
const bobJoin = function(this: CryptoTestContext) {
|
||||||
|
cy.botJoinRoomByName(this.bob, "Alice").as("bobsRoom");
|
||||||
|
cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerificationRequest = (request: VerificationRequest): Chainable<EmojiMapping[]> => {
|
||||||
|
return cy.wrap(new Promise<EmojiMapping[]>((resolve) => {
|
||||||
|
const onShowSas = (event: ISasEvent) => {
|
||||||
|
resolve(event.sas.emoji);
|
||||||
|
verifier.off("show_sas", onShowSas);
|
||||||
|
event.confirm();
|
||||||
|
verifier.done();
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifier = request.beginKeyVerification("m.sas.v1");
|
||||||
|
verifier.on("show_sas", onShowSas);
|
||||||
|
verifier.verify();
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const verify = function(this: CryptoTestContext) {
|
||||||
|
const bobsVerificationRequestPromise = waitForVerificationRequest(this.bob);
|
||||||
|
|
||||||
|
openRoomInfo().within(() => {
|
||||||
|
cy.get(".mx_RoomSummaryCard_icon_people").click();
|
||||||
|
cy.contains(".mx_EntityTile_name", "Bob").click();
|
||||||
|
cy.contains(".mx_UserInfo_verifyButton", "Verify").click();
|
||||||
|
cy.contains(".mx_AccessibleButton", "Start Verification").click();
|
||||||
|
cy.wrap(bobsVerificationRequestPromise).then((verificationRequest: VerificationRequest) => {
|
||||||
|
verificationRequest.accept();
|
||||||
|
return verificationRequest;
|
||||||
|
}).as("bobsVerificationRequest");
|
||||||
|
cy.contains(".mx_AccessibleButton", "Verify by emoji").click();
|
||||||
|
cy.get<VerificationRequest>("@bobsVerificationRequest").then((request: VerificationRequest) => {
|
||||||
|
return handleVerificationRequest(request).then((emojis: EmojiMapping[]) => {
|
||||||
|
cy.get('.mx_VerificationShowSas_emojiSas_block').then((emojiBlocks) => {
|
||||||
|
emojis.forEach((emoji: EmojiMapping, index: number) => {
|
||||||
|
expect(emojiBlocks[index].textContent.toLowerCase()).to.eq(emoji[0] + emoji[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cy.contains(".mx_AccessibleButton", "They match").click();
|
||||||
|
cy.contains("You've successfully verified Bob!").should("exist");
|
||||||
|
cy.contains(".mx_AccessibleButton", "Got it").click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Cryptography", function() {
|
||||||
|
beforeEach(function() {
|
||||||
|
cy.startSynapse("default").as("synapse").then((synapse: SynapseInstance) => {
|
||||||
|
cy.initTestUser(synapse, "Alice");
|
||||||
|
cy.getBot(synapse, { displayName: "Bob", autoAcceptInvites: false }).as("bob");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function(this: CryptoTestContext) {
|
||||||
|
cy.stopSynapse(this.synapse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setting up secure key backup should work", () => {
|
||||||
|
cy.openUserSettings("Security & Privacy");
|
||||||
|
cy.contains(".mx_AccessibleButton", "Set up Secure Backup").click();
|
||||||
|
cy.get(".mx_Dialog").within(() => {
|
||||||
|
cy.contains(".mx_Dialog_primary", "Continue").click();
|
||||||
|
cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey");
|
||||||
|
// Clicking download instead of Copy because of https://github.com/cypress-io/cypress/issues/2851
|
||||||
|
cy.contains(".mx_AccessibleButton", "Download").click();
|
||||||
|
cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click();
|
||||||
|
cy.contains(".mx_Dialog_title", "Setting up keys").should("exist");
|
||||||
|
cy.contains(".mx_Dialog_title", "Setting up keys").should("not.exist");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a DM should work, being e2e-encrypted / user verification", function(this: CryptoTestContext) {
|
||||||
|
cy.bootstrapCrossSigning();
|
||||||
|
startDMWithBob.call(this);
|
||||||
|
checkDMRoom();
|
||||||
|
bobJoin.call(this);
|
||||||
|
testMessages.call(this);
|
||||||
|
verify.call(this);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -50,3 +50,6 @@ signing_key_path: "/data/localhost.signing.key"
|
||||||
trusted_key_servers:
|
trusted_key_servers:
|
||||||
- server_name: "matrix.org"
|
- server_name: "matrix.org"
|
||||||
suppress_key_server_warning: true
|
suppress_key_server_warning: true
|
||||||
|
|
||||||
|
ui_auth:
|
||||||
|
session_timeout: "300s"
|
||||||
|
|
|
@ -18,10 +18,25 @@ limitations under the License.
|
||||||
|
|
||||||
import request from "browser-request";
|
import request from "browser-request";
|
||||||
|
|
||||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||||
import { SynapseInstance } from "../plugins/synapsedocker";
|
import { SynapseInstance } from "../plugins/synapsedocker";
|
||||||
import Chainable = Cypress.Chainable;
|
import Chainable = Cypress.Chainable;
|
||||||
|
|
||||||
|
interface CreateBotOpts {
|
||||||
|
/**
|
||||||
|
* Whether the bot should automatically accept all invites.
|
||||||
|
*/
|
||||||
|
autoAcceptInvites?: boolean;
|
||||||
|
/**
|
||||||
|
* The display name to give to that bot user
|
||||||
|
*/
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCreateBotOptions = {
|
||||||
|
autoAcceptInvites: true,
|
||||||
|
} as CreateBotOpts;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
|
@ -29,17 +44,30 @@ declare global {
|
||||||
/**
|
/**
|
||||||
* Returns a new Bot instance
|
* Returns a new Bot instance
|
||||||
* @param synapse the instance on which to register the bot user
|
* @param synapse the instance on which to register the bot user
|
||||||
* @param displayName the display name to give to the bot user
|
* @param opts create bot options
|
||||||
*/
|
*/
|
||||||
getBot(synapse: SynapseInstance, displayName?: string): Chainable<MatrixClient>;
|
getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable<MatrixClient>;
|
||||||
|
/**
|
||||||
|
* Let a bot join a room
|
||||||
|
* @param cli The bot's MatrixClient
|
||||||
|
* @param roomId ID of the room to join
|
||||||
|
*/
|
||||||
|
botJoinRoom(cli: MatrixClient, roomId: string): Chainable<Room>;
|
||||||
|
/**
|
||||||
|
* Let a bot join a room by name
|
||||||
|
* @param cli The bot's MatrixClient
|
||||||
|
* @param roomName Name of the room to join
|
||||||
|
*/
|
||||||
|
botJoinRoomByName(cli: MatrixClient, roomName: string): Chainable<Room>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): Chainable<MatrixClient> => {
|
Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable<MatrixClient> => {
|
||||||
|
opts = Object.assign({}, defaultCreateBotOptions, opts);
|
||||||
const username = Cypress._.uniqueId("userId_");
|
const username = Cypress._.uniqueId("userId_");
|
||||||
const password = Cypress._.uniqueId("password_");
|
const password = Cypress._.uniqueId("password_");
|
||||||
return cy.registerUser(synapse, username, password, displayName).then(credentials => {
|
return cy.registerUser(synapse, username, password, opts.displayName).then(credentials => {
|
||||||
return cy.window({ log: false }).then(win => {
|
return cy.window({ log: false }).then(win => {
|
||||||
const cli = new win.matrixcs.MatrixClient({
|
const cli = new win.matrixcs.MatrixClient({
|
||||||
baseUrl: synapse.baseUrl,
|
baseUrl: synapse.baseUrl,
|
||||||
|
@ -52,18 +80,37 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string):
|
||||||
cryptoStore: new win.matrixcs.MemoryCryptoStore(),
|
cryptoStore: new win.matrixcs.MemoryCryptoStore(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts.autoAcceptInvites) {
|
||||||
cli.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => {
|
cli.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => {
|
||||||
if (member.membership === "invite" && member.userId === cli.getUserId()) {
|
if (member.membership === "invite" && member.userId === cli.getUserId()) {
|
||||||
cli.joinRoom(member.roomId);
|
cli.joinRoom(member.roomId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return cy.wrap(
|
return cy.wrap(
|
||||||
cli.initCrypto()
|
cli.initCrypto()
|
||||||
.then(() => cli.setGlobalErrorOnUnknownDevices(false))
|
.then(() => cli.setGlobalErrorOnUnknownDevices(false))
|
||||||
.then(() => cli.startClient())
|
.then(() => cli.startClient())
|
||||||
|
.then(() => cli.bootstrapCrossSigning({
|
||||||
|
authUploadDeviceSigningKeys: async func => { await func({}); },
|
||||||
|
}))
|
||||||
.then(() => cli),
|
.then(() => cli),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("botJoinRoom", (cli: MatrixClient, roomId: string): Chainable<Room> => {
|
||||||
|
return cy.wrap(cli.joinRoom(roomId));
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("botJoinRoomByName", (cli: MatrixClient, roomName: string): Chainable<Room> => {
|
||||||
|
const room = cli.getRooms().find((r) => r.getDefaultRoomName(cli.getUserId()) === roomName);
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
return cy.botJoinRoom(cli, room.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cy.wrap(Promise.reject());
|
||||||
|
});
|
||||||
|
|
|
@ -53,6 +53,10 @@ declare global {
|
||||||
* @param data The data to store.
|
* @param data The data to store.
|
||||||
*/
|
*/
|
||||||
setAccountData(type: string, data: object): Chainable<{}>;
|
setAccountData(type: string, data: object): Chainable<{}>;
|
||||||
|
/**
|
||||||
|
* Boostraps cross-signing.
|
||||||
|
*/
|
||||||
|
bootstrapCrossSigning(): Chainable<void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -103,3 +107,11 @@ Cypress.Commands.add("setAccountData", (type: string, data: object): Chainable<{
|
||||||
return cli.setAccountData(type, data);
|
return cli.setAccountData(type, data);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("bootstrapCrossSigning", () => {
|
||||||
|
cy.window({ log: false }).then(win => {
|
||||||
|
win.mxMatrixClientPeg.matrixClient.bootstrapCrossSigning({
|
||||||
|
authUploadDeviceSigningKeys: async func => { await func({}); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -936,6 +936,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
||||||
disabled={this.state.busy || (this.props.kind == KIND_CALL_TRANSFER && this.state.targets.length > 0)}
|
disabled={this.state.busy || (this.props.kind == KIND_CALL_TRANSFER && this.state.targets.length > 0)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder={hasPlaceholder ? _t("Search") : null}
|
placeholder={hasPlaceholder ? _t("Search") : null}
|
||||||
|
data-test-id="invite-dialog-input"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue