Move SlashCommands and SlashCommands-test into subdirs (#31979)

* Move SlashCommands into slash-commands

* Move SlashCommands test prep into a function that can be re-used from multiple files

* Move slash command tests into separate files

* Fix super-linear regexes and test some more slash commands

* Move splitAtFirstSpace tests into a separate file

* Test for parseCommandString

* Make parseCommandString able to handle leading whitespace and tabs

* Implement parseCommandString using splitAtFirstSpace

* Extract emoticons slash commands into a separate file and share their code
This commit is contained in:
Andy Balaam 2026-02-09 11:08:51 +00:00 committed by GitHub
parent 6589e0b6df
commit 562f7cc2bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 904 additions and 561 deletions

View File

@ -17,7 +17,7 @@ import AutocompleteProvider from "./AutocompleteProvider";
import QueryMatcher from "./QueryMatcher";
import { TextualCompletion } from "./Components";
import { type ICompletion, type ISelectionRange } from "./Autocompleter";
import { type Command, Commands, CommandMap } from "../SlashCommands";
import { type Command, Commands, CommandMap } from "../slash-commands/SlashCommands";
import { type TimelineRenderingType } from "../contexts/RoomContext";
import { MatrixClientPeg } from "../MatrixClientPeg";

View File

@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { _t } from "../../../languageHandler";
import { type Command, CommandCategories, Commands } from "../../../SlashCommands";
import { type Command, CommandCategories, Commands } from "../../../slash-commands/SlashCommands";
import InfoDialog from "./InfoDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";

View File

@ -29,7 +29,7 @@ import { parseEvent, parsePlainTextMessage } from "../../../editor/deserialize";
import { renderModel } from "../../../editor/render";
import SettingsStore from "../../../settings/SettingsStore";
import { IS_MAC, Key } from "../../../Keyboard";
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
import { CommandCategories, CommandMap, parseCommandString } from "../../../slash-commands/SlashCommands";
import Range from "../../../editor/range";
import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar";
import type DocumentOffset from "../../../editor/offset";

View File

@ -27,7 +27,7 @@ import { parseEvent } from "../../../editor/deserialize";
import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } from "../../../editor/parts";
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandCategories } from "../../../SlashCommands";
import { CommandCategories } from "../../../slash-commands/SlashCommands";
import { Action } from "../../../dispatcher/actions";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import SendHistoryManager from "../../../SendHistoryManager";

View File

@ -36,7 +36,7 @@ import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } from "../../../editor/parts";
import { findEditableEvent } from "../../../utils/EventUtils";
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from "../../../SlashCommands";
import { CommandCategories } from "../../../slash-commands/SlashCommands";
import ContentMessages from "../../../ContentMessages";
import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions";

View File

@ -29,7 +29,7 @@ import { endEditing, cancelPreviousPendingEdit } from "./editing";
import type EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { createMessageContent, EMOTE_PREFIX } from "./createMessageContent";
import { isContentModified } from "./isContentModified";
import { CommandCategories, getCommand } from "../../../../../SlashCommands";
import { CommandCategories, getCommand } from "../../../../../slash-commands/SlashCommands";
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
import { Action } from "../../../../../dispatcher/actions";
import { addReplyToMessageContent } from "../../../../../utils/Reply";

View File

@ -13,7 +13,7 @@ import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
import type EditorModel from "./model";
import { Type } from "./parts";
import { type Command, CommandCategories, getCommand } from "../SlashCommands";
import { type Command, CommandCategories, getCommand } from "../slash-commands/SlashCommands";
import { UserFriendlyError, _t, _td } from "../languageHandler";
import Modal from "../Modal";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";

View File

@ -21,45 +21,46 @@ import {
import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types";
import dis from "./dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "./languageHandler";
import Modal from "./Modal";
import MultiInviter from "./utils/MultiInviter";
import { Linkify, topicToHtml } from "./HtmlUtils";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import WidgetUtils from "./utils/WidgetUtils";
import { textToHtmlRainbow } from "./utils/colour";
import { AddressType, getAddressType } from "./UserAddress";
import { abbreviateUrl } from "./utils/UrlUtils";
import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "./utils/IdentityServerUtils";
import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi";
import BugReportDialog from "./components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "./createRoom";
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { Action } from "./dispatcher/actions";
import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore";
import { UIComponent, UIFeature } from "./settings/UIFeature";
import { CHAT_EFFECTS } from "./effects";
import LegacyCallHandler from "./LegacyCallHandler";
import { guessAndSetDMRoom } from "./Rooms";
import DevtoolsDialog from "./components/views/dialogs/DevtoolsDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
import { TimelineRenderingType } from "./contexts/RoomContext";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { isCurrentLocalRoom, reject, singleMxcUpload, success, successSync } from "./slash-commands/utils";
import { deop, op } from "./slash-commands/op";
import { CommandCategories } from "./slash-commands/interface";
import { Command } from "./slash-commands/command";
import { goto, join } from "./slash-commands/join";
import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog";
import upgraderoom from "./slash-commands/upgraderoom/upgraderoom";
import dis from "../dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "../languageHandler";
import Modal from "../Modal";
import MultiInviter from "../utils/MultiInviter";
import { Linkify, topicToHtml } from "../HtmlUtils";
import QuestionDialog from "../components/views/dialogs/QuestionDialog";
import WidgetUtils from "../utils/WidgetUtils";
import { textToHtmlRainbow } from "../utils/colour";
import { AddressType, getAddressType } from "../UserAddress";
import { abbreviateUrl } from "../utils/UrlUtils";
import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../utils/IdentityServerUtils";
import { WidgetType } from "../widgets/WidgetType";
import { Jitsi } from "../widgets/Jitsi";
import BugReportDialog from "../components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "../createRoom";
import { type ViewUserPayload } from "../dispatcher/payloads/ViewUserPayload";
import { Action } from "../dispatcher/actions";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import { UIComponent, UIFeature } from "../settings/UIFeature";
import { CHAT_EFFECTS } from "../effects";
import LegacyCallHandler from "../LegacyCallHandler";
import { guessAndSetDMRoom } from "../Rooms";
import DevtoolsDialog from "../components/views/dialogs/DevtoolsDialog";
import InfoDialog from "../components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "../components/views/dialogs/SlashCommandHelpDialog";
import { shouldShowComponent } from "../customisations/helpers/UIComponents";
import { TimelineRenderingType } from "../contexts/RoomContext";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { htmlSerializeFromMdIfNeeded } from "../editor/serialize";
import { leaveRoomBehaviour } from "../utils/leave-behaviour";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { isCurrentLocalRoom, reject, singleMxcUpload, success, successSync } from "./utils";
import { deop, op } from "./op";
import { CommandCategories } from "./interface";
import { Command } from "./command";
import { goto, join } from "./join";
import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog";
import upgraderoom from "./upgraderoom/upgraderoom";
import { emoticon } from "./emoticon";
export { CommandCategories, Command };
@ -73,58 +74,10 @@ export const Commands = [
},
category: CommandCategories.messages,
}),
new Command({
command: "shrug",
args: "<message>",
description: _td("slash_command|shrug"),
runFn: function (cli, roomId, threadId, args) {
let message = "¯\\_(ツ)_/¯";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: "tableflip",
args: "<message>",
description: _td("slash_command|tableflip"),
runFn: function (cli, roomId, threadId, args) {
let message = "(╯°□°)╯︵ ┻━┻";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: "unflip",
args: "<message>",
description: _td("slash_command|unflip"),
runFn: function (cli, roomId, threadId, args) {
let message = "┬──┬ ( ゜-゜ノ)";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: "lenny",
args: "<message>",
description: _td("slash_command|lenny"),
runFn: function (cli, roomId, threadId, args) {
let message = "( ͡° ͜ʖ ͡°)";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
emoticon("shrug", _td("slash_command|shrug"), "¯\\_(ツ)_/¯"),
emoticon("tableflip", _td("slash_command|tableflip"), "(╯°□°)╯︵ ┻━┻"),
emoticon("unflip", _td("slash_command|unflip"), "┬──┬ ( ゜-゜ノ)"),
emoticon("lenny", _td("slash_command|lenny"), "( ͡° ͜ʖ ͡°)"),
new Command({
command: "plain",
args: "<message>",
@ -348,7 +301,7 @@ export const Commands = [
isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const [address, reason] = args.split(/\s+(.+)/);
const [address, reason] = splitAtFirstSpace(args);
if (address) {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
@ -460,9 +413,9 @@ export const Commands = [
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(cli.kick(roomId, matches[1], matches[3]));
const [userId, reason] = splitAtFirstSpace(args);
if (userId) {
return success(cli.kick(roomId, userId, reason));
}
}
return reject(this.getUsage());
@ -477,9 +430,9 @@ export const Commands = [
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(cli.ban(roomId, matches[1], matches[3]));
const [userId, reason] = splitAtFirstSpace(args);
if (userId) {
return success(cli.ban(roomId, userId, reason));
}
}
return reject(this.getUsage());
@ -784,9 +737,8 @@ export const Commands = [
runFn: function (cli, roomId, threadId, args) {
if (args) {
// matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
if (matches) {
const [userId, msg] = matches.slice(1);
const [userId, msg] = splitAtFirstSpace(args);
if (userId !== "") {
if (userId && userId.startsWith("@") && userId.includes(":")) {
return success(
(async (): Promise<void> => {
@ -910,21 +862,24 @@ Commands.forEach((cmd) => {
});
});
/**
* If the supplied input starts with "/<command>", returns an object with these
* properties:
*
* cmd - the string following the / up to some whitespace
* args - the string (if any) after first whitespace
*
* If not, returns {}
*/
export function parseCommandString(input: string): { cmd?: string; args?: string } {
// trim any trailing whitespace, as it can confuse the parser for IRC-style commands
input = input.trimEnd();
if (!input.startsWith("/")) return {}; // not a command
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
let cmd: string;
let args: string | undefined;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[2];
} else {
cmd = input;
const trimmedInput = input.trimStart();
if (trimmedInput.charAt(0) !== "/") {
return {};
}
const withoutSlash = trimmedInput.slice(1);
const [cmd, args] = splitAtFirstSpace(withoutSlash);
return { cmd, args };
}
@ -933,6 +888,26 @@ interface ICmd {
args?: string;
}
/**
* Split the supplied string into one or two strings separated by the first
* region of white space we can find.
*/
export function splitAtFirstSpace(args: string): [string, string?] {
const trimmedArgs = args.trim();
const i = trimmedArgs.search(/\s+/);
if (i === -1) {
return [trimmedArgs];
} else {
const first = trimmedArgs.slice(0, i);
const second = trimmedArgs.slice(i + 1).trimStart();
if (second === "") {
return [first];
} else {
return [first, second];
}
}
}
/**
* Process the given text for /commands and returns a parsed command that can be used for running the operation.
* @param {string} roomId The room ID where the command was issued.

View File

@ -0,0 +1,27 @@
/*
* Copyright 2026 Element Creations 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 { ContentHelpers } from "matrix-js-sdk/src/matrix";
import { Command } from "./command";
import { successSync } from "./utils";
import { CommandCategories } from "./interface";
export function emoticon(command: string, description: TranslationKey, message: string): Command {
return new Command({
command,
args: "<message>",
description,
runFn: function (_cli, _roomId, _threadId, args) {
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
});
}

View File

@ -351,6 +351,10 @@ export function createTestClient(): MatrixClient {
},
search: jest.fn().mockResolvedValue({}),
processRoomEventsSearch: jest.fn().mockResolvedValue({ highlights: [], results: [] }),
invite: jest.fn(),
kick: jest.fn(),
ban: jest.fn(),
sendTextMessage: jest.fn(),
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);

View File

@ -1,362 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import { act, waitFor } from "jest-matrix-react";
import { type Command, Commands, getCommand } from "../../src/SlashCommands";
import { createTestClient } from "../test-utils";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom";
import { SdkContextClass } from "../../src/contexts/SDKContext";
import Modal, { type ComponentType, type IHandle } from "../../src/Modal";
import WidgetUtils from "../../src/utils/WidgetUtils";
import { WidgetType } from "../../src/widgets/WidgetType";
import { warnSelfDemote } from "../../src/components/views/right_panel/UserInfo";
import dispatcher from "../../src/dispatcher/dispatcher";
import QuestionDialog from "../../src/components/views/dialogs/QuestionDialog";
import ErrorDialog from "../../src/components/views/dialogs/ErrorDialog";
jest.mock("../../src/components/views/right_panel/UserInfo");
describe("SlashCommands", () => {
let client: MatrixClient;
const roomId = "!room:example.com";
let room: Room;
const localRoomId = LOCAL_ROOM_ID_PREFIX + "test";
let localRoom: LocalRoom;
let command: Command;
const findCommand = (cmd: string): Command | undefined => {
return Commands.find((command: Command) => command.command === cmd);
};
const setCurrentRoom = (): void => {
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId);
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
if (rId === roomId) return room;
return null;
});
};
const setCurrentLocalRoom = (): void => {
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId);
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
if (rId === localRoomId) return localRoom;
return null;
});
};
beforeEach(() => {
jest.clearAllMocks();
client = createTestClient();
room = new Room(roomId, client, client.getSafeUserId());
localRoom = new LocalRoom(localRoomId, client, client.getSafeUserId());
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId");
});
describe("/topic", () => {
it("sets topic", async () => {
const command = getCommand(roomId, "/topic pizza");
expect(command.cmd).toBeDefined();
expect(command.args).toBeDefined();
await command.cmd!.run(client, "room-id", null, command.args);
expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined);
});
it("should show topic modal if no args passed", async () => {
const spy = jest.spyOn(Modal, "createDialog");
const command = getCommand(roomId, "/topic")!;
await command.cmd!.run(client, roomId, null);
expect(spy).toHaveBeenCalled();
});
});
describe.each([
["myroomnick"],
["roomavatar"],
["myroomavatar"],
["topic"],
["roomname"],
["invite"],
["part"],
["remove"],
["ban"],
["unban"],
["op"],
["deop"],
["addwidget"],
["discardsession"],
["whois"],
["holdcall"],
["unholdcall"],
["converttodm"],
["converttoroom"],
])("/%s", (commandName: string) => {
beforeEach(() => {
command = findCommand(commandName)!;
});
describe("isEnabled", () => {
it("should return true for Room", () => {
setCurrentRoom();
expect(command.isEnabled(client, roomId)).toBe(true);
});
it("should return false for LocalRoom", () => {
setCurrentLocalRoom();
expect(command.isEnabled(client, roomId)).toBe(false);
});
});
});
describe("/op", () => {
beforeEach(() => {
command = findCommand("op")!;
});
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should reject with usage if given an invalid power level value", () => {
expect(command.run(client, roomId, null, "@bob:server Admin").error).toBe(command.getUsage());
});
it("should reject with usage for invalid input", () => {
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
});
it("should warn about self demotion", async () => {
setCurrentRoom();
const member = new RoomMember(roomId, client.getSafeUserId());
member.membership = KnownMembership.Join;
member.powerLevel = 100;
room.getMember = () => member;
command.run(client, roomId, null, `${client.getUserId()} 0`);
expect(warnSelfDemote).toHaveBeenCalled();
});
it("should default to 50 if no powerlevel specified", async () => {
setCurrentRoom();
const member = new RoomMember(roomId, "@user:server");
member.membership = KnownMembership.Join;
room.getMember = () => member;
command.run(client, roomId, null, member.userId);
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, member.userId, 50);
});
});
describe("/deop", () => {
beforeEach(() => {
command = findCommand("deop")!;
});
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should warn about self demotion", async () => {
setCurrentRoom();
const member = new RoomMember(roomId, client.getSafeUserId());
member.membership = KnownMembership.Join;
member.powerLevel = 100;
room.getMember = () => member;
command.run(client, roomId, null, client.getSafeUserId());
expect(warnSelfDemote).toHaveBeenCalled();
});
it("should reject with usage for invalid input", () => {
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
});
});
describe("/part", () => {
it("should part room matching alias if found", async () => {
const room1 = new Room("room-id", client, client.getSafeUserId());
room1.getCanonicalAlias = jest.fn().mockReturnValue("#foo:bar");
const room2 = new Room("other-room", client, client.getSafeUserId());
room2.getCanonicalAlias = jest.fn().mockReturnValue("#baz:bar");
mocked(client.getRooms).mockReturnValue([room1, room2]);
const command = getCommand(room1.roomId, "/part #foo:bar");
expect(command.cmd).toBeDefined();
expect(command.args).toBeDefined();
await command.cmd!.run(client, room1.roomId, null, command.args);
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
});
it("should part room matching alt alias if found", async () => {
const room1 = new Room("room-id", client, client.getSafeUserId());
room1.getAltAliases = jest.fn().mockReturnValue(["#foo:bar"]);
const room2 = new Room("other-room", client, client.getSafeUserId());
room2.getAltAliases = jest.fn().mockReturnValue(["#baz:bar"]);
mocked(client.getRooms).mockReturnValue([room1, room2]);
const command = getCommand(room1.roomId, "/part #foo:bar");
expect(command.cmd).toBeDefined();
expect(command.args).toBeDefined();
await command.cmd!.run(client, room1.roomId, null, command.args!);
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
});
});
describe.each(["rainbow", "rainbowme"])("/%s", (commandName: string) => {
const command = findCommand(commandName)!;
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should make things rainbowy", () => {
return expect(
command.run(client, roomId, null, "this is a test message").promise,
).resolves.toMatchSnapshot();
});
});
describe.each(["shrug", "tableflip", "unflip", "lenny"])("/%s", (commandName: string) => {
const command = findCommand(commandName)!;
it("should match snapshot with no args", () => {
return expect(command.run(client, roomId, null).promise).resolves.toMatchSnapshot();
});
it("should match snapshot with args", () => {
return expect(
command.run(client, roomId, null, "this is a test message").promise,
).resolves.toMatchSnapshot();
});
});
describe("/verify", () => {
it("should return usage if no args", () => {
const command = findCommand("verify")!;
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should attempt manual verification after confirmation", async () => {
// Given we say yes to prompt
const spy = jest.spyOn(Modal, "createDialog");
spy.mockReturnValue({ finished: Promise.resolve([true]) } as unknown as IHandle<ComponentType>);
// When we run the command
const command = findCommand("verify")!;
await act(() => command.run(client, roomId, null, "mydeviceid myfingerprint"));
// Then the prompt is displayed
expect(spy).toHaveBeenCalledWith(
QuestionDialog,
expect.objectContaining({ title: "Caution: manual device verification" }),
);
// And then we attempt the verification
await waitFor(() =>
expect(spy).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({ title: "Verification failed" }),
),
);
});
it("should not do manual verification if cancelled", async () => {
// Given we say no to prompt
const spy = jest.spyOn(Modal, "createDialog");
spy.mockReturnValue({ finished: Promise.resolve([false]) } as unknown as IHandle<ComponentType>);
// When we run the command
const command = findCommand("verify")!;
command.run(client, roomId, null, "mydeviceid myfingerprint");
// Then the prompt is displayed
expect(spy).toHaveBeenCalledWith(
QuestionDialog,
expect.objectContaining({ title: "Caution: manual device verification" }),
);
// But nothing else happens
expect(spy).not.toHaveBeenCalledWith(ErrorDialog, expect.anything());
});
});
describe("/addwidget", () => {
it("should parse html iframe snippets", async () => {
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
const spy = jest.spyOn(WidgetUtils, "setRoomWidget");
const command = findCommand("addwidget")!;
await command.run(client, roomId, null, '<iframe src="https://element.io"></iframe>');
expect(spy).toHaveBeenCalledWith(
client,
roomId,
expect.any(String),
WidgetType.CUSTOM,
"https://element.io",
"Custom",
{},
);
});
});
describe("/join", () => {
beforeEach(() => {
jest.spyOn(dispatcher, "dispatch");
command = findCommand(KnownMembership.Join)!;
});
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should handle matrix.org permalinks", () => {
command.run(client, roomId, null, "https://matrix.to/#/!roomId:server/$eventId");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_id: "!roomId:server",
event_id: "$eventId",
highlighted: true,
}),
);
});
it("should handle room aliases", () => {
command.run(client, roomId, null, "#test:server");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_alias: "#test:server",
}),
);
});
it("should handle room aliases with no server component", () => {
command.run(client, roomId, null, "#test");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_alias: `#test:${client.getDomain()}`,
}),
);
});
it("should handle room IDs and via servers", () => {
command.run(client, roomId, null, "!foo:bar serv1.com serv2.com");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_id: "!foo:bar",
via_servers: ["serv1.com", "serv2.com"],
}),
);
});
});
});

View File

@ -12,7 +12,7 @@ import { stubClient } from "../../test-utils";
import { Command } from "../../../src/slash-commands/command";
import { CommandCategories } from "../../../src/slash-commands/interface";
import { _td } from "../../../src/languageHandler";
import * as SlashCommands from "../../../src/SlashCommands";
import * as SlashCommands from "../../../src/slash-commands/SlashCommands";
describe("CommandProvider", () => {
let room: Room;

View File

@ -13,7 +13,7 @@ import { stubClient } from "../../../../test-utils";
import { Command } from "../../../../../src/slash-commands/command";
import { CommandCategories } from "../../../../../src/slash-commands/interface";
import { _t, _td } from "../../../../../src/languageHandler";
import * as SlashCommands from "../../../../../src/SlashCommands";
import * as SlashCommands from "../../../../../src/slash-commands/SlashCommands";
describe("SlashCommandHelpDialog", () => {
const roomId = "!room:server";

View File

@ -19,7 +19,7 @@ import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../../../src/settings/SettingLevel";
import EditorStateTransfer from "../../../../../../../src/utils/EditorStateTransfer";
import * as ConfirmRedactDialog from "../../../../../../../src/components/views/dialogs/ConfirmRedactDialog";
import * as SlashCommands from "../../../../../../../src/SlashCommands";
import * as SlashCommands from "../../../../../../../src/slash-commands/SlashCommands";
import * as Commands from "../../../../../../../src/editor/commands";
import * as Reply from "../../../../../../../src/utils/Reply";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";

View File

@ -0,0 +1,57 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`/lenny should match snapshot with args 1`] = `
{
"body": "( ͡° ͜ʖ ͡°) this is a test message",
"msgtype": "m.text",
}
`;
exports[`/lenny should match snapshot with no args 1`] = `
{
"body": "( ͡° ͜ʖ ͡°)",
"msgtype": "m.text",
}
`;
exports[`/shrug should match snapshot with args 1`] = `
{
"body": "¯\\_(ツ)_/¯ this is a test message",
"msgtype": "m.text",
}
`;
exports[`/shrug should match snapshot with no args 1`] = `
{
"body": "¯\\_(ツ)_/¯",
"msgtype": "m.text",
}
`;
exports[`/tableflip should match snapshot with args 1`] = `
{
"body": "(╯°□°)╯︵ ┻━┻ this is a test message",
"msgtype": "m.text",
}
`;
exports[`/tableflip should match snapshot with no args 1`] = `
{
"body": "(╯°□°)╯︵ ┻━┻",
"msgtype": "m.text",
}
`;
exports[`/unflip should match snapshot with args 1`] = `
{
"body": "┬──┬ ( ゜-゜ノ) this is a test message",
"msgtype": "m.text",
}
`;
exports[`/unflip should match snapshot with no args 1`] = `
{
"body": "┬──┬ ( ゜-゜ノ)",
"msgtype": "m.text",
}
`;

View File

@ -1,20 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`SlashCommands /lenny should match snapshot with args 1`] = `
{
"body": "( ͡° ͜ʖ ͡°) this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /lenny should match snapshot with no args 1`] = `
{
"body": "( ͡° ͜ʖ ͡°)",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /rainbow should make things rainbowy 1`] = `
exports[`/rainbow should make things rainbowy 1`] = `
{
"body": "this is a test message",
"format": "org.matrix.custom.html",
@ -23,7 +9,7 @@ exports[`SlashCommands /rainbow should make things rainbowy 1`] = `
}
`;
exports[`SlashCommands /rainbowme should make things rainbowy 1`] = `
exports[`/rainbowme should make things rainbowy 1`] = `
{
"body": "this is a test message",
"format": "org.matrix.custom.html",
@ -31,45 +17,3 @@ exports[`SlashCommands /rainbowme should make things rainbowy 1`] = `
"msgtype": "m.emote",
}
`;
exports[`SlashCommands /shrug should match snapshot with args 1`] = `
{
"body": "¯\\_(ツ)_/¯ this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /shrug should match snapshot with no args 1`] = `
{
"body": "¯\\_(ツ)_/¯",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /tableflip should match snapshot with args 1`] = `
{
"body": "(╯°□°)╯︵ ┻━┻ this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /tableflip should match snapshot with no args 1`] = `
{
"body": "(╯°□°)╯︵ ┻━┻",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /unflip should match snapshot with args 1`] = `
{
"body": "┬──┬ ( ゜-゜ノ) this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /unflip should match snapshot with no args 1`] = `
{
"body": "┬──┬ ( ゜-゜ノ)",
"msgtype": "m.text",
}
`;

View File

@ -0,0 +1,39 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { waitFor } from "jest-matrix-react";
import WidgetUtils from "../../../src/utils/WidgetUtils";
import { setUpCommandTest } from "./utils";
import { WidgetType } from "../../../src/widgets/WidgetType";
describe("/addwidget", () => {
const roomId = "!room:example.com";
it("should parse html iframe snippets", async () => {
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
const spy = jest.spyOn(WidgetUtils, "setRoomWidget");
const { client, command } = setUpCommandTest(roomId, `/addwidget`);
command.run(client, roomId, null, '<iframe src="https://element.io"></iframe>');
await waitFor(() =>
expect(spy).toHaveBeenCalledWith(
client,
roomId,
expect.any(String),
WidgetType.CUSTOM,
"https://element.io",
"Custom",
{},
),
);
});
});

View File

@ -0,0 +1,33 @@
/*
* Copyright 2026 Element Creations 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 { setUpCommandTest } from "./utils";
describe("/ban", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, `/ban`);
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should ban the user we specify from this room", async () => {
const { client, command, args } = setUpCommandTest(roomId, `/ban @u:s.co`);
await command.run(client, roomId, null, args).promise;
expect(client.ban).toHaveBeenCalledWith(roomId, "@u:s.co", undefined);
});
it("should provide the ban reason if we supply it", async () => {
const { client, command, args } = setUpCommandTest(roomId, `/ban @u:s.co They were quite nasty`);
await command.run(client, roomId, null, args).promise;
expect(client.ban).toHaveBeenCalledWith(roomId, "@u:s.co", "They were quite nasty");
});
});

View File

@ -0,0 +1,48 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { setUpCommandTest } from "./utils";
describe("SlashCommands", () => {
const roomId = "!room:example.com";
describe.each([
["myroomnick"],
["roomavatar"],
["myroomavatar"],
["topic"],
["roomname"],
["invite"],
["part"],
["remove"],
["ban"],
["unban"],
["op"],
["deop"],
["addwidget"],
["discardsession"],
["whois"],
["holdcall"],
["unholdcall"],
["converttodm"],
["converttoroom"],
])("/%s", (commandName: string) => {
describe("isEnabled", () => {
it("should return true for Room", () => {
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
expect(command.isEnabled(client, roomId)).toBe(true);
});
it("should return false for LocalRoom", () => {
const { client, command } = setUpCommandTest(roomId, `/${commandName}`, true);
expect(command.isEnabled(client, roomId)).toBe(false);
});
});
});
});

View File

@ -0,0 +1,25 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { setUpCommandTest } from "./utils";
describe.each(["shrug", "tableflip", "unflip", "lenny"])("/%s", (commandName: string) => {
const roomId = "!room:example.com";
it("should match snapshot with no args", async () => {
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
await expect(command.run(client, roomId, null).promise).resolves.toMatchSnapshot();
});
it("should match snapshot with args", async () => {
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
await expect(command.run(client, roomId, null, "this is a test message").promise).resolves.toMatchSnapshot();
});
});

View File

@ -0,0 +1,40 @@
/*
* Copyright 2026 Element Creations 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 Modal, { type ComponentType, type IHandle } from "../../../src/Modal";
import { setUpCommandTest } from "./utils";
describe("/invite", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, `/invite`);
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should invite the user we specify to this room", async () => {
const spy = jest.spyOn(Modal, "createDialog");
spy.mockReturnValue({ close: () => {} } as unknown as IHandle<ComponentType>);
const { client, command, args } = setUpCommandTest(roomId, `/invite @u:s.co`);
await command.run(client, roomId, null, args).promise;
expect(client.invite).toHaveBeenCalledWith(roomId, "@u:s.co", {});
});
it("should provide the invite reason if we supply it", async () => {
const spy = jest.spyOn(Modal, "createDialog");
spy.mockReturnValue({ close: () => {} } as unknown as IHandle<ComponentType>);
const { client, command, args } = setUpCommandTest(roomId, `/invite @u:s.co They are a very nice person`);
await command.run(client, roomId, null, args).promise;
expect(client.invite).toHaveBeenCalledWith(roomId, "@u:s.co", { reason: "They are a very nice person" });
});
});

View File

@ -0,0 +1,79 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { setUpCommandTest } from "./utils";
import dispatcher from "../../../src/dispatcher/dispatcher";
describe("/join", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, `/join`);
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should handle matrix.org permalinks", async () => {
const { client, command } = setUpCommandTest(roomId, `/join`);
jest.spyOn(dispatcher, "dispatch");
await command.run(client, roomId, null, "https://matrix.to/#/!roomId:server/$eventId").promise;
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_id: "!roomId:server",
event_id: "$eventId",
highlighted: true,
}),
);
});
it("should handle room aliases", async () => {
const { client, command } = setUpCommandTest(roomId, `/join`);
jest.spyOn(dispatcher, "dispatch");
await command.run(client, roomId, null, "#test:server").promise;
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_alias: "#test:server",
}),
);
});
it("should handle room aliases with no server component", async () => {
const { client, command } = setUpCommandTest(roomId, `/join`);
jest.spyOn(dispatcher, "dispatch");
await command.run(client, roomId, null, "#test").promise;
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_alias: `#test:${client.getDomain()}`,
}),
);
});
it("should handle room IDs and via servers", async () => {
const { client, command } = setUpCommandTest(roomId, `/join`);
jest.spyOn(dispatcher, "dispatch");
await command.run(client, roomId, null, "!foo:bar serv1.com serv2.com").promise;
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_id: "!foo:bar",
via_servers: ["serv1.com", "serv2.com"],
}),
);
});
});

View File

@ -0,0 +1,46 @@
/*
* Copyright 2026 Element Creations 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 { setUpCommandTest } from "./utils";
import dispatcher from "../../../src/dispatcher/dispatcher";
import DMRoomMap from "../../../src/utils/DMRoomMap";
describe("/msg", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, `/msg`);
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should message the user and switch to the relevant DM", async () => {
// Given there is no DM room with the user
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getDMRoomsForUserId: jest.fn().mockReturnValue([]),
getRoomIds: jest.fn().mockReturnValue([roomId]),
} as unknown as DMRoomMap);
jest.spyOn(dispatcher, "dispatch");
// When we send a message to that user
const { client, command, args } = setUpCommandTest(roomId, `/msg @u:s.co Hello there`);
await command.run(client, roomId, null, args).promise;
// Then we create a room and send the message in there
expect(client.sendTextMessage).toHaveBeenCalledWith("!1:example.org", "Hello there");
// And tell the UI to switch to that room
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
metricsTrigger: "SlashCommand",
metricsViaKeyboard: true,
room_id: "!1:example.org",
}),
);
});
});

View File

@ -0,0 +1,77 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { KnownMembership, RoomMember } from "matrix-js-sdk/src/matrix";
import { setUpCommandTest } from "./utils";
import { warnSelfDemote } from "../../../src/components/views/right_panel/UserInfo";
jest.mock("../../../src/components/views/right_panel/UserInfo");
describe("/op", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command, args } = setUpCommandTest(roomId, "/op");
expect(command.run(client, roomId, null, args).error).toBe(command.getUsage());
});
it("should reject with usage if given an invalid power level value", () => {
const { client, command, args } = setUpCommandTest(roomId, "/op @bob:server Admin");
expect(command.run(client, roomId, null, args).error).toBe(command.getUsage());
});
it("should reject with usage for invalid input", () => {
const { client, command } = setUpCommandTest(roomId, "/op");
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
});
it("should warn about self demotion", async () => {
const { client, command, room } = setUpCommandTest(roomId, "/op");
const member = new RoomMember(roomId, client.getSafeUserId());
member.membership = KnownMembership.Join;
member.powerLevel = 100;
room.getMember = () => member;
command.run(client, roomId, null, `${client.getUserId()} 0`);
expect(warnSelfDemote).toHaveBeenCalled();
});
it("should default to 50 if no powerlevel specified", async () => {
const { client, command, room } = setUpCommandTest(roomId, "/op");
const member = new RoomMember(roomId, "@user:server");
member.membership = KnownMembership.Join;
room.getMember = () => member;
command.run(client, roomId, null, member.userId);
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, member.userId, 50);
});
});
describe("/deop", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, "/deop");
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should warn about self demotion", async () => {
const { client, command, room } = setUpCommandTest(roomId, "/deop");
const member = new RoomMember(roomId, client.getSafeUserId());
member.membership = KnownMembership.Join;
member.powerLevel = 100;
room.getMember = () => member;
await command.run(client, roomId, null, client.getSafeUserId()).promise;
expect(warnSelfDemote).toHaveBeenCalled();
});
it("should reject with usage for invalid input", () => {
const { client, command } = setUpCommandTest(roomId, "/deop");
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
});
});

View File

@ -0,0 +1,22 @@
/*
* Copyright 2026 Element Creations 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 { parseCommandString } from "../../../src/slash-commands/SlashCommands";
describe("parseCommandString", () => {
it("should be able to split arguments at the first whitespace", () => {
expect(parseCommandString("/a b")).toEqual({ cmd: "a", args: "b" });
expect(parseCommandString("/cmd And more stuff")).toEqual({ cmd: "cmd", args: "And more stuff" });
expect(parseCommandString("/cmd And more stuff")).toEqual({ cmd: "cmd", args: "And more stuff" });
expect(parseCommandString("/cmd And more\nstuff")).toEqual({ cmd: "cmd", args: "And more\nstuff" });
expect(parseCommandString("/cmd \t\n And more stuff")).toEqual({ cmd: "cmd", args: "And more stuff" });
expect(parseCommandString("/a")).toEqual({ cmd: "a" });
expect(parseCommandString("/cmd")).toEqual({ cmd: "cmd" });
expect(parseCommandString("/cmd ")).toEqual({ cmd: "cmd" });
expect(parseCommandString(" /cmd ")).toEqual({ cmd: "cmd" });
});
});

View File

@ -0,0 +1,72 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import Modal, { type ComponentType, type IHandle } from "../../../src/Modal";
import { setUpCommandTest } from "./utils";
import { type Command } from "../../../src/slash-commands/command";
describe("/part", () => {
const roomId = "!room:example.com";
function setUp(): {
client: MatrixClient;
command: Command;
args?: string;
room1: Room;
room2: Room;
} {
const spy = jest.spyOn(Modal, "createDialog");
spy.mockReturnValue({ close: () => {} } as unknown as IHandle<ComponentType>);
const { client, command, args } = setUpCommandTest(roomId, "/part #foo:bar");
expect(args).toBeDefined();
const room1 = new Room("!room-id", client, client.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const room2 = new Room("!other-room", client, client.getSafeUserId());
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
if (rId === room1.roomId) {
return room1;
} else if (rId === room2.roomId) {
return room2;
} else {
return null;
}
});
mocked(client.getRooms).mockReturnValue([room1, room2]);
return { client, command, args, room1, room2 };
}
it("should part room matching alias if found", async () => {
const { client, command, args, room1, room2 } = setUp();
room1.getCanonicalAlias = jest.fn().mockReturnValue("#foo:bar");
room2.getCanonicalAlias = jest.fn().mockReturnValue("#baz:bar");
await command.run(client, room1.roomId, null, args).promise;
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
});
it("should part room matching alt alias if found", async () => {
const { client, command, args, room1, room2 } = setUp();
room1.getAltAliases = jest.fn().mockReturnValue(["#foo:bar"]);
room2.getAltAliases = jest.fn().mockReturnValue(["#baz:bar"]);
await command.run(client, room1.roomId, null, args).promise;
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
});
});

View File

@ -0,0 +1,25 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { setUpCommandTest } from "./utils";
describe.each(["rainbow", "rainbowme"])("/%s", (commandName: string) => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should make things rainbowy", async () => {
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
await expect(command.run(client, roomId, null, "this is a test message").promise).resolves.toMatchSnapshot();
});
});

View File

@ -0,0 +1,33 @@
/*
* Copyright 2026 Element Creations 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 { setUpCommandTest } from "./utils";
describe("/remove", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, `/remove`);
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should kick the user we specify from this room", async () => {
const { client, command, args } = setUpCommandTest(roomId, `/remove @u:s.co`);
await command.run(client, roomId, null, args).promise;
expect(client.kick).toHaveBeenCalledWith(roomId, "@u:s.co", undefined);
});
it("should provide the kick reason if we supply it", async () => {
const { client, command, args } = setUpCommandTest(roomId, `/remove @u:s.co They were not very nice`);
await command.run(client, roomId, null, args).promise;
expect(client.kick).toHaveBeenCalledWith(roomId, "@u:s.co", "They were not very nice");
});
});

View File

@ -0,0 +1,22 @@
/*
* Copyright 2026 Element Creations 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 { splitAtFirstSpace } from "../../../src/slash-commands/SlashCommands";
describe("splitAtFirstSpace", () => {
it("should be able to split arguments at the first whitespace", () => {
expect(splitAtFirstSpace("a b")).toEqual(["a", "b"]);
expect(splitAtFirstSpace("arg1 Followed by more stuff")).toEqual(["arg1", "Followed by more stuff"]);
expect(splitAtFirstSpace("arg1 Followed by more\nstuff")).toEqual(["arg1", "Followed by more\nstuff"]);
expect(splitAtFirstSpace(" arg1 Followed by more stuff ")).toEqual(["arg1", "Followed by more stuff"]);
expect(splitAtFirstSpace("arg1 \t\n Followed by more stuff")).toEqual(["arg1", "Followed by more stuff"]);
expect(splitAtFirstSpace("a")).toEqual(["a"]);
expect(splitAtFirstSpace("arg1")).toEqual(["arg1"]);
expect(splitAtFirstSpace("arg1 ")).toEqual(["arg1"]);
expect(splitAtFirstSpace(" arg1 ")).toEqual(["arg1"]);
});
});

View File

@ -0,0 +1,31 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import Modal from "../../../src/Modal";
import { setUpCommandTest } from "./utils";
describe("/topic", () => {
const roomId = "!room:example.com";
it("sets topic", async () => {
const { client, command, args } = setUpCommandTest(roomId, "/topic pizza");
expect(args).toBeDefined();
command.run(client, "room-id", null, args);
expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined);
});
it("should show topic modal if no args passed", async () => {
const spy = jest.spyOn(Modal, "createDialog");
const { client, command } = setUpCommandTest(roomId, "/topic");
await command.run(client, roomId, null).promise;
expect(spy).toHaveBeenCalled();
});
});

View File

@ -1,29 +1,25 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import RoomUpgradeWarningDialog, {
type IFinishedOpts,
} from "../../../src/components/views/dialogs/RoomUpgradeWarningDialog";
import { type Command, Commands } from "../../../src/SlashCommands";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { createTestClient } from "../../test-utils";
import { type Command } from "../../../src/slash-commands/SlashCommands";
import { parseUpgradeRoomArgs } from "../../../src/slash-commands/upgraderoom/parseUpgradeRoomArgs";
import Modal from "../../../src/Modal";
import { setUpCommandTest } from "./utils";
describe("/upgraderoom", () => {
const roomId = "!room:example.com";
function findCommand(cmd: string): Command | undefined {
return Commands.find((command: Command) => command.command === cmd);
}
/**
* Set up an upgraderoom test.
*
@ -39,15 +35,7 @@ describe("/upgraderoom", () => {
} {
jest.clearAllMocks();
const command = findCommand("upgraderoom")!;
const client = createTestClient();
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId");
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId);
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
if (rId === roomId) return new Room(roomId, client, client.getSafeUserId());
return null;
});
const { command, client } = setUpCommandTest(roomId, "/upgraderoom");
const createDialog = jest.spyOn(Modal, "createDialog");
const upgradeRoom = jest.fn().mockResolvedValue({ replacement_room: "!newroom" });

View File

@ -0,0 +1,54 @@
/*
* Copyright 2026 Element Creations 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 { Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { type Command } from "../../../src/slash-commands/command";
import { getCommand } from "../../../src/slash-commands/SlashCommands";
import { stubClient } from "../../test-utils";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { LocalRoom } from "../../../src/models/LocalRoom";
export function setUpCommandTest(
roomId: string,
input: string,
roomIsLocal?: boolean,
): {
command: Command;
args?: string;
client: MatrixClient;
room: Room;
} {
jest.clearAllMocks();
// TODO: if getCommand took a MatrixClient argument, we could use
// createTestClient here instead of stubClient (i.e. avoid setting
// MatrixClientPeg.)
const client = stubClient();
const { cmd: command, args } = getCommand(roomId, input);
let room: Room;
if (roomIsLocal) {
room = new LocalRoom(roomId, client, client.getSafeUserId());
} else {
room = new Room(roomId, client, client.getSafeUserId());
}
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(roomId);
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
if (rId === roomId) {
return room;
} else {
return null;
}
});
return { command: command!, args, client, room };
}

View File

@ -0,0 +1,64 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { act, waitFor } from "jest-matrix-react";
import Modal, { type ComponentType, type IHandle } from "../../../src/Modal";
import { setUpCommandTest } from "./utils";
import QuestionDialog from "../../../src/components/views/dialogs/QuestionDialog";
import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog";
describe("/verify", () => {
const roomId = "!room:example.com";
it("should return usage if no args", () => {
const { client, command } = setUpCommandTest(roomId, `/verify`);
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should attempt manual verification after confirmation", async () => {
// Given we say yes to prompt
const spy = jest.spyOn(Modal, "createDialog");
spy.mockReturnValue({ finished: Promise.resolve([true]) } as unknown as IHandle<ComponentType>);
// When we run the command
const { client, command } = setUpCommandTest(roomId, `/verify`);
await act(() => command.run(client, roomId, null, "mydeviceid myfingerprint"));
// Then the prompt is displayed
expect(spy).toHaveBeenCalledWith(
QuestionDialog,
expect.objectContaining({ title: "Caution: manual device verification" }),
);
// And then we attempt the verification
await waitFor(() =>
expect(spy).toHaveBeenCalledWith(ErrorDialog, expect.objectContaining({ title: "Verification failed" })),
);
});
it("should not do manual verification if cancelled", async () => {
// Given we say no to prompt
const spy = jest.spyOn(Modal, "createDialog");
spy.mockReturnValue({ finished: Promise.resolve([false]) } as unknown as IHandle<ComponentType>);
// When we run the command
const { client, command } = setUpCommandTest(roomId, `/verify`);
command.run(client, roomId, null, "mydeviceid myfingerprint");
// Then the prompt is displayed
expect(spy).toHaveBeenCalledWith(
QuestionDialog,
expect.objectContaining({ title: "Caution: manual device verification" }),
);
// But nothing else happens
expect(spy).not.toHaveBeenCalledWith(ErrorDialog, expect.anything());
});
});