diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index 41781c0a68..f93ea6c8ce 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -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"; diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.tsx b/src/components/views/dialogs/SlashCommandHelpDialog.tsx index 0ac1a0de0d..6ceb80a1fd 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.tsx +++ b/src/components/views/dialogs/SlashCommandHelpDialog.tsx @@ -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"; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d15e90e394..3e9d46cbf6 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -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"; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 51cd5093bf..970d8cffcd 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -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"; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 122048d85a..0d9a68c45d 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -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"; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts index ace513947b..2594ac4841 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -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"; diff --git a/src/editor/commands.tsx b/src/editor/commands.tsx index 6346785755..7d1125341a 100644 --- a/src/editor/commands.tsx +++ b/src/editor/commands.tsx @@ -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"; diff --git a/src/SlashCommands.tsx b/src/slash-commands/SlashCommands.tsx similarity index 87% rename from src/SlashCommands.tsx rename to src/slash-commands/SlashCommands.tsx index 0169513635..a566a5f093 100644 --- a/src/SlashCommands.tsx +++ b/src/slash-commands/SlashCommands.tsx @@ -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: "", - 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: "", - 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: "", - 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: "", - 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: "", @@ -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 => { @@ -910,21 +862,24 @@ Commands.forEach((cmd) => { }); }); +/** + * If the supplied input starts with "/", 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. diff --git a/src/slash-commands/emoticon.ts b/src/slash-commands/emoticon.ts new file mode 100644 index 0000000000..c1d232bca9 --- /dev/null +++ b/src/slash-commands/emoticon.ts @@ -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: "", + description, + runFn: function (_cli, _roomId, _threadId, args) { + if (args) { + message = message + " " + args; + } + return successSync(ContentHelpers.makeTextMessage(message)); + }, + category: CommandCategories.messages, + }); +} diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 1159fa3fb7..a894608b24 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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); diff --git a/test/unit-tests/SlashCommands-test.tsx b/test/unit-tests/SlashCommands-test.tsx deleted file mode 100644 index 76be3fb92f..0000000000 --- a/test/unit-tests/SlashCommands-test.tsx +++ /dev/null @@ -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); - - // 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); - - // 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, ''); - 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"], - }), - ); - }); - }); -}); diff --git a/test/unit-tests/autocomplete/CommandProvider-test.ts b/test/unit-tests/autocomplete/CommandProvider-test.ts index f282063901..ed02dd1adf 100644 --- a/test/unit-tests/autocomplete/CommandProvider-test.ts +++ b/test/unit-tests/autocomplete/CommandProvider-test.ts @@ -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; diff --git a/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx b/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx index 00e4aa96db..c57c0b7503 100644 --- a/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx @@ -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"; diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts index c5dbea367d..c53db5de35 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -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"; diff --git a/test/unit-tests/slash-commands/__snapshots__/emoticons-test.ts.snap b/test/unit-tests/slash-commands/__snapshots__/emoticons-test.ts.snap new file mode 100644 index 0000000000..dc52a779b3 --- /dev/null +++ b/test/unit-tests/slash-commands/__snapshots__/emoticons-test.ts.snap @@ -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", +} +`; diff --git a/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap b/test/unit-tests/slash-commands/__snapshots__/rainbow-test.ts.snap similarity index 55% rename from test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap rename to test/unit-tests/slash-commands/__snapshots__/rainbow-test.ts.snap index 472feb6dcf..0b439253a1 100644 --- a/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap +++ b/test/unit-tests/slash-commands/__snapshots__/rainbow-test.ts.snap @@ -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", -} -`; diff --git a/test/unit-tests/slash-commands/addwidget-test.ts b/test/unit-tests/slash-commands/addwidget-test.ts new file mode 100644 index 0000000000..90e45a7c81 --- /dev/null +++ b/test/unit-tests/slash-commands/addwidget-test.ts @@ -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, ''); + + await waitFor(() => + expect(spy).toHaveBeenCalledWith( + client, + roomId, + expect.any(String), + WidgetType.CUSTOM, + "https://element.io", + "Custom", + {}, + ), + ); + }); +}); diff --git a/test/unit-tests/slash-commands/ban-test.ts b/test/unit-tests/slash-commands/ban-test.ts new file mode 100644 index 0000000000..c898ebcd9e --- /dev/null +++ b/test/unit-tests/slash-commands/ban-test.ts @@ -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"); + }); +}); diff --git a/test/unit-tests/slash-commands/disabled-in-local-room-test.ts b/test/unit-tests/slash-commands/disabled-in-local-room-test.ts new file mode 100644 index 0000000000..8a7fb8eb57 --- /dev/null +++ b/test/unit-tests/slash-commands/disabled-in-local-room-test.ts @@ -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); + }); + }); + }); +}); diff --git a/test/unit-tests/slash-commands/emoticons-test.ts b/test/unit-tests/slash-commands/emoticons-test.ts new file mode 100644 index 0000000000..bd30059127 --- /dev/null +++ b/test/unit-tests/slash-commands/emoticons-test.ts @@ -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(); + }); +}); diff --git a/test/unit-tests/slash-commands/invite-test.ts b/test/unit-tests/slash-commands/invite-test.ts new file mode 100644 index 0000000000..574292a4ae --- /dev/null +++ b/test/unit-tests/slash-commands/invite-test.ts @@ -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); + + 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); + + 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" }); + }); +}); diff --git a/test/unit-tests/slash-commands/join-test.ts b/test/unit-tests/slash-commands/join-test.ts new file mode 100644 index 0000000000..aa1e742788 --- /dev/null +++ b/test/unit-tests/slash-commands/join-test.ts @@ -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"], + }), + ); + }); +}); diff --git a/test/unit-tests/slash-commands/msg-test.ts b/test/unit-tests/slash-commands/msg-test.ts new file mode 100644 index 0000000000..a55cbfc082 --- /dev/null +++ b/test/unit-tests/slash-commands/msg-test.ts @@ -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", + }), + ); + }); +}); diff --git a/test/unit-tests/slash-commands/op-test.ts b/test/unit-tests/slash-commands/op-test.ts new file mode 100644 index 0000000000..e0a843c2b0 --- /dev/null +++ b/test/unit-tests/slash-commands/op-test.ts @@ -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()); + }); +}); diff --git a/test/unit-tests/slash-commands/parse-command-string-test.ts b/test/unit-tests/slash-commands/parse-command-string-test.ts new file mode 100644 index 0000000000..9f2fa1a3dc --- /dev/null +++ b/test/unit-tests/slash-commands/parse-command-string-test.ts @@ -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" }); + }); +}); diff --git a/test/unit-tests/slash-commands/part-test.ts b/test/unit-tests/slash-commands/part-test.ts new file mode 100644 index 0000000000..1253712163 --- /dev/null +++ b/test/unit-tests/slash-commands/part-test.ts @@ -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); + + 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()); + }); +}); diff --git a/test/unit-tests/slash-commands/rainbow-test.ts b/test/unit-tests/slash-commands/rainbow-test.ts new file mode 100644 index 0000000000..d86c307d69 --- /dev/null +++ b/test/unit-tests/slash-commands/rainbow-test.ts @@ -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(); + }); +}); diff --git a/test/unit-tests/slash-commands/remove-test.ts b/test/unit-tests/slash-commands/remove-test.ts new file mode 100644 index 0000000000..2ba29757fa --- /dev/null +++ b/test/unit-tests/slash-commands/remove-test.ts @@ -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"); + }); +}); diff --git a/test/unit-tests/slash-commands/split-at-first-space-test.ts b/test/unit-tests/slash-commands/split-at-first-space-test.ts new file mode 100644 index 0000000000..2bbbb87b3b --- /dev/null +++ b/test/unit-tests/slash-commands/split-at-first-space-test.ts @@ -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"]); + }); +}); diff --git a/test/unit-tests/slash-commands/topic-test.ts b/test/unit-tests/slash-commands/topic-test.ts new file mode 100644 index 0000000000..c77a9961b0 --- /dev/null +++ b/test/unit-tests/slash-commands/topic-test.ts @@ -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(); + }); +}); diff --git a/test/unit-tests/slash-commands/upgraderoom-test.tsx b/test/unit-tests/slash-commands/upgraderoom-test.tsx index 88aea8a833..c13c9fc81a 100644 --- a/test/unit-tests/slash-commands/upgraderoom-test.tsx +++ b/test/unit-tests/slash-commands/upgraderoom-test.tsx @@ -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" }); diff --git a/test/unit-tests/slash-commands/utils.ts b/test/unit-tests/slash-commands/utils.ts new file mode 100644 index 0000000000..5654128100 --- /dev/null +++ b/test/unit-tests/slash-commands/utils.ts @@ -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 }; +} diff --git a/test/unit-tests/slash-commands/verify-test.ts b/test/unit-tests/slash-commands/verify-test.ts new file mode 100644 index 0000000000..e5e768f4a0 --- /dev/null +++ b/test/unit-tests/slash-commands/verify-test.ts @@ -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); + + // 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); + + // 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()); + }); +});