diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 02e26729d2..4f47cd7eac 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -46,6 +46,7 @@ import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; import { getUserDeviceIds } from "./utils/crypto/deviceInfo"; +import { asyncSomeParallel } from "./utils/arrays.ts"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -240,13 +241,16 @@ export default class DeviceListener { return this.keyBackupInfo; } - private shouldShowSetupEncryptionToast(): boolean { + private async shouldShowSetupEncryptionToast(): Promise { // If we're in the middle of a secret storage operation, we're likely // modifying the state involved here, so don't add new toasts to setup. if (isSecretStorageBeingAccessed()) return false; // Show setup toasts once the user is in at least one encrypted room. const cli = this.client; - return cli?.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)) ?? false; + const cryptoApi = cli?.getCrypto(); + if (!cli || !cryptoApi) return false; + + return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId)); } private recheck(): void { @@ -283,7 +287,7 @@ export default class DeviceListener { hideSetupEncryptionToast(); this.checkKeyBackupStatus(); - } else if (this.shouldShowSetupEncryptionToast()) { + } else if (await this.shouldShowSetupEncryptionToast()) { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); diff --git a/src/Searching.ts b/src/Searching.ts index 85483eb23c..252d4378ad 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -596,7 +596,7 @@ async function combinedPagination( return result; } -function eventIndexSearch( +async function eventIndexSearch( client: MatrixClient, term: string, roomId?: string, @@ -605,7 +605,7 @@ function eventIndexSearch( let searchPromise: Promise; if (roomId !== undefined) { - if (client.isRoomEncrypted(roomId)) { + if (await client.getCrypto()?.isEncryptionEnabledInRoom(roomId)) { // The search is for a single encrypted room, use our local // search method. searchPromise = localSearchProcess(client, term, roomId); diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index e3ca77f988..11872d059e 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -229,7 +229,7 @@ export class SlidingSyncManager { subscriptions.delete(roomId); } const room = this.client?.getRoom(roomId); - let shouldLazyLoad = !this.client?.isRoomEncrypted(roomId); + let shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId)); if (!room) { // default to safety: request all state if we can't work it out. This can happen if you // refresh the app whilst viewing a room: we call setRoomVisible before we know anything diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index afd444c952..e51dd96647 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -427,7 +427,7 @@ export default class MatrixChat extends React.PureComponent { } } else if ( (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) && - !shouldSkipSetupEncryption(cli) + !(await shouldSkipSetupEncryption(cli)) ) { // if cross-signing is not yet set up, do so now if possible. this.setStateForNewView({ view: Views.E2E_SETUP }); diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 75b9977dc4..3234c2be35 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -43,6 +43,10 @@ interface IState { // If we know it, the nature of the abuse, as specified by MSC3215. nature?: ExtendedNature; ignoreUserToo: boolean; // if true, user will be ignored/blocked on submit + /* + * Whether the room is encrypted. + */ + isRoomEncrypted: boolean; } const MODERATED_BY_STATE_EVENT_TYPE = [ @@ -188,9 +192,20 @@ export default class ReportEventDialog extends React.Component { // If specified, the nature of the abuse, as specified by MSC3215. nature: undefined, ignoreUserToo: false, // default false, for now. Could easily be argued as default true + isRoomEncrypted: false, // async, will be set later }; } + public componentDidMount = async (): Promise => { + const crypto = MatrixClientPeg.safeGet().getCrypto(); + const roomId = this.props.mxEvent.getRoomId(); + if (!crypto || !roomId) return; + + this.setState({ + isRoomEncrypted: await crypto.isEncryptionEnabledInRoom(roomId), + }); + }; + private onIgnoreUserTooChanged = (newVal: boolean): void => { this.setState({ ignoreUserToo: newVal }); }; @@ -319,7 +334,6 @@ export default class ReportEventDialog extends React.Component { if (this.moderation) { // Display report-to-moderator dialog. // We let the user pick a nature. - const client = MatrixClientPeg.safeGet(); const homeServerName = SdkConfig.get("validated_server_config")!.hsName; let subtitle: string; switch (this.state.nature) { @@ -336,7 +350,7 @@ export default class ReportEventDialog extends React.Component { subtitle = _t("report_content|nature_spam"); break; case NonStandardValue.Admin: - if (client.isRoomEncrypted(this.props.mxEvent.getRoomId()!)) { + if (this.state.isRoomEncrypted) { subtitle = _t("report_content|nature_nonstandard_admin_encrypted", { homeserver: homeServerName, }); diff --git a/src/components/views/dialogs/devtools/RoomNotifications.tsx b/src/components/views/dialogs/devtools/RoomNotifications.tsx index c54e695006..1bcff78487 100644 --- a/src/components/views/dialogs/devtools/RoomNotifications.tsx +++ b/src/components/views/dialogs/devtools/RoomNotifications.tsx @@ -17,6 +17,7 @@ import { determineUnreadState } from "../../../../RoomNotifs"; import { humanReadableNotificationLevel } from "../../../../stores/notifications/NotificationLevel"; import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread"; import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; +import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts"; function UserReadUpTo({ target }: { target: ReadReceipt }): JSX.Element { const cli = useContext(MatrixClientContext); @@ -59,6 +60,7 @@ function UserReadUpTo({ target }: { target: ReadReceipt }): JSX.Elemen export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Element { const { room } = useContext(DevtoolsContext); const cli = useContext(MatrixClientContext); + const isRoomEncrypted = useIsEncrypted(cli, room); const { level, count } = determineUnreadState(room, undefined, false); const [notificationState] = useNotificationState(room); @@ -93,9 +95,7 @@ export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Eleme
  • {_t( - cli.isRoomEncrypted(room.roomId!) - ? _td("devtools|room_encrypted") - : _td("devtools|room_not_encrypted"), + isRoomEncrypted ? _td("devtools|room_encrypted") : _td("devtools|room_not_encrypted"), {}, { strong: (sub) => {sub}, diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index e721662cb5..bc6680d300 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -6,18 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, useContext } from "react"; +import React, { forwardRef } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; import { _t } from "../../../languageHandler"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import EventTileBubble from "./EventTileBubble"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import DMRoomMap from "../../../utils/DMRoomMap"; import { objectHasDiff } from "../../../utils/objects"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto"; +import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts"; interface IProps { mxEvent: MatrixEvent; @@ -25,9 +25,9 @@ interface IProps { } const EncryptionEvent = forwardRef(({ mxEvent, timestamp }, ref) => { - const cli = useContext(MatrixClientContext); + const cli = useMatrixClientContext(); const roomId = mxEvent.getRoomId()!; - const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId); + const isRoomEncrypted = useIsEncrypted(cli, cli.getRoom(roomId) || undefined); const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent; const content = mxEvent.getContent(); diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 99c69b9891..d1a35f2950 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -328,6 +328,28 @@ export async function asyncSome(values: Iterable, predicate: (value: T) => return false; } +/** + * Async version of Array.some that runs all promises in parallel. + * @param values + * @param predicate + */ +export async function asyncSomeParallel( + values: Array, + predicate: (value: T) => Promise, +): Promise { + try { + return await Promise.any( + values.map((value) => + predicate(value).then((result) => (result ? Promise.resolve(true) : Promise.reject(false))), + ), + ); + } catch (e) { + // If the array is empty or all the promises are false, Promise.any will reject an AggregateError + if (e instanceof AggregateError) return false; + throw e; + } +} + export function filterBoolean(values: Array): T[] { return values.filter(Boolean) as T[]; } diff --git a/src/utils/crypto/shouldSkipSetupEncryption.ts b/src/utils/crypto/shouldSkipSetupEncryption.ts index 51d7a9303c..d4dbb27d1b 100644 --- a/src/utils/crypto/shouldSkipSetupEncryption.ts +++ b/src/utils/crypto/shouldSkipSetupEncryption.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption"; +import { asyncSomeParallel } from "../arrays.ts"; /** * If encryption is force disabled AND the user is not in any encrypted rooms @@ -16,7 +17,13 @@ import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption"; * @param client * @returns {boolean} true when we can skip settings up encryption */ -export const shouldSkipSetupEncryption = (client: MatrixClient): boolean => { +export const shouldSkipSetupEncryption = async (client: MatrixClient): Promise => { const isEncryptionForceDisabled = shouldForceDisableEncryption(client); - return isEncryptionForceDisabled && !client.getRooms().some((r) => client.isRoomEncrypted(r.roomId)); + const crypto = client.getCrypto(); + if (!crypto) return true; + + return ( + isEncryptionForceDisabled && + !(await asyncSomeParallel(client.getRooms(), ({ roomId }) => crypto.isEncryptionEnabledInRoom(roomId))) + ); }; diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 0a5798d8a1..7842afbfe5 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -162,6 +162,7 @@ export const mockClientMethodsCrypto = (): Partial< getVersion: jest.fn().mockReturnValue("Version 0"), getOwnDeviceKeys: jest.fn().mockReturnValue(new Promise(() => {})), getCrossSigningKeyId: jest.fn(), + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), }), }); diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index 0862c6b385..906826e456 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -95,6 +95,7 @@ describe("DeviceListener", () => { }, }), getSessionBackupPrivateKey: jest.fn(), + isEncryptionEnabledInRoom: jest.fn(), } as unknown as Mocked; mockClient = getMockClientWithEventEmitter({ isGuest: jest.fn(), @@ -105,7 +106,6 @@ describe("DeviceListener", () => { isVersionSupported: jest.fn().mockResolvedValue(true), isInitialSyncComplete: jest.fn().mockReturnValue(true), waitForClientWellKnown: jest.fn(), - isRoomEncrypted: jest.fn(), getClientWellKnown: jest.fn(), getDeviceId: jest.fn().mockReturnValue(deviceId), setAccountData: jest.fn(), @@ -292,7 +292,7 @@ describe("DeviceListener", () => { mockCrypto!.isCrossSigningReady.mockResolvedValue(false); mockCrypto!.isSecretStorageReady.mockResolvedValue(false); mockClient!.getRooms.mockReturnValue(rooms); - mockClient!.isRoomEncrypted.mockReturnValue(true); + jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); }); it("hides setup encryption toast when cross signing and secret storage are ready", async () => { @@ -317,7 +317,7 @@ describe("DeviceListener", () => { }); it("does not show any toasts when no rooms are encrypted", async () => { - mockClient!.isRoomEncrypted.mockReturnValue(false); + jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); await createAndStart(); expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 16106ee0d2..b3766bfc89 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -146,7 +146,6 @@ describe("", () => { matrixRTC: createStubMatrixRTC(), getDehydratedDevice: jest.fn(), whoami: jest.fn(), - isRoomEncrypted: jest.fn(), logout: jest.fn(), getDeviceId: jest.fn(), getKeyBackupVersion: jest.fn().mockResolvedValue(null), @@ -1011,6 +1010,7 @@ describe("", () => { userHasCrossSigningKeys: jest.fn().mockResolvedValue(false), // This needs to not finish immediately because we need to test the screen appears bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise), + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), }; loginClient.getCrypto.mockReturnValue(mockCrypto as any); }); @@ -1058,9 +1058,11 @@ describe("", () => { }, }); - loginClient.isRoomEncrypted.mockImplementation((roomId) => { - return roomId === encryptedRoom.roomId; - }); + jest.spyOn(loginClient.getCrypto()!, "isEncryptionEnabledInRoom").mockImplementation( + async (roomId) => { + return roomId === encryptedRoom.roomId; + }, + ); }); it("should go straight to logged in view when user is not in any encrypted rooms", async () => { diff --git a/test/unit-tests/components/structures/MessagePanel-test.tsx b/test/unit-tests/components/structures/MessagePanel-test.tsx index 037a57bb06..cf44716ba9 100644 --- a/test/unit-tests/components/structures/MessagePanel-test.tsx +++ b/test/unit-tests/components/structures/MessagePanel-test.tsx @@ -23,6 +23,7 @@ import { createTestClient, getMockClientWithEventEmitter, makeBeaconInfoEvent, + mockClientMethodsCrypto, mockClientMethodsEvents, mockClientMethodsUser, } from "../../../test-utils"; @@ -42,6 +43,7 @@ describe("MessagePanel", function () { const client = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsEvents(), + ...mockClientMethodsCrypto(), getAccountData: jest.fn(), isUserIgnored: jest.fn().mockReturnValue(false), isRoomEncrypted: jest.fn().mockReturnValue(false), diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index 02bed8cf4f..f30db3d80e 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -21,6 +21,7 @@ import { SearchResult, IEvent, } from "matrix-js-sdk/src/matrix"; +import { CryptoApi, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { fireEvent, render, screen, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; @@ -72,6 +73,7 @@ describe("RoomView", () => { let rooms: Map; let roomCount = 0; let stores: SdkContextClass; + let crypto: CryptoApi; // mute some noise filterConsole("RVS update", "does not have an m.room.create event", "Current version: 1", "Version capability"); @@ -97,6 +99,7 @@ describe("RoomView", () => { stores.rightPanelStore.useUnitTestClient(cli); jest.spyOn(VoipUserMapper.sharedInstance(), "getVirtualRoomForRoom").mockResolvedValue(undefined); + crypto = cli.getCrypto()!; jest.spyOn(cli, "getCrypto").mockReturnValue(undefined); }); @@ -341,7 +344,13 @@ describe("RoomView", () => { describe("that is encrypted", () => { beforeEach(() => { + // Not all the calls to cli.isRoomEncrypted are migrated, so we need to mock both. mocked(cli.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, true, false), + ); localRoom.encrypted = true; localRoom.currentState.setStateEvents([ new MatrixEvent({ @@ -360,7 +369,7 @@ describe("RoomView", () => { it("should match the snapshot", async () => { const { container } = await renderRoomView(); - expect(container).toMatchSnapshot(); + await waitFor(() => expect(container).toMatchSnapshot()); }); }); }); diff --git a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx index 3a78ef55e8..ca5f3d04b9 100644 --- a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx +++ b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx @@ -10,6 +10,7 @@ import React from "react"; import { mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { render, screen } from "jest-matrix-react"; +import { waitFor } from "@testing-library/dom"; import EncryptionEvent from "../../../../../src/components/views/messages/EncryptionEvent"; import { createTestClient, mkMessage } from "../../../../test-utils"; @@ -55,17 +56,19 @@ describe("EncryptionEvent", () => { describe("for an encrypted room", () => { beforeEach(() => { event.event.content!.algorithm = algorithm; - mocked(client.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); const room = new Room(roomId, client, client.getUserId()!); mocked(client.getRoom).mockReturnValue(room); }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { renderEncryptionEvent(client, event); - checkTexts( - "Encryption enabled", - "Messages in this room are end-to-end encrypted. " + - "When people join, you can verify them in their profile, just tap on their profile picture.", + await waitFor(() => + checkTexts( + "Encryption enabled", + "Messages in this room are end-to-end encrypted. " + + "When people join, you can verify them in their profile, just tap on their profile picture.", + ), ); }); @@ -76,9 +79,9 @@ describe("EncryptionEvent", () => { }); }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { renderEncryptionEvent(client, event); - checkTexts("Encryption enabled", "Some encryption parameters have been changed."); + await waitFor(() => checkTexts("Encryption enabled", "Some encryption parameters have been changed.")); }); }); @@ -87,36 +90,38 @@ describe("EncryptionEvent", () => { event.event.content!.algorithm = "unknown"; }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { renderEncryptionEvent(client, event); - checkTexts("Encryption enabled", "Ignored attempt to disable encryption"); + await waitFor(() => checkTexts("Encryption enabled", "Ignored attempt to disable encryption")); }); }); }); describe("for an unencrypted room", () => { beforeEach(() => { - mocked(client.isRoomEncrypted).mockReturnValue(false); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); renderEncryptionEvent(client, event); }); - it("should show the expected texts", () => { - expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId); - checkTexts("Encryption not enabled", "The encryption used by this room isn't supported."); + it("should show the expected texts", async () => { + expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); + await waitFor(() => + checkTexts("Encryption not enabled", "The encryption used by this room isn't supported."), + ); }); }); describe("for an encrypted local room", () => { beforeEach(() => { event.event.content!.algorithm = algorithm; - mocked(client.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); const localRoom = new LocalRoom(roomId, client, client.getUserId()!); mocked(client.getRoom).mockReturnValue(localRoom); renderEncryptionEvent(client, event); }); it("should show the expected texts", () => { - expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId); + expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); }); }); diff --git a/test/unit-tests/utils/arrays-test.ts b/test/unit-tests/utils/arrays-test.ts index 53baed8be3..52b0053147 100644 --- a/test/unit-tests/utils/arrays-test.ts +++ b/test/unit-tests/utils/arrays-test.ts @@ -23,6 +23,7 @@ import { concat, asyncEvery, asyncSome, + asyncSomeParallel, } from "../../../src/utils/arrays"; type TestParams = { input: number[]; output: number[] }; @@ -460,4 +461,23 @@ describe("arrays", () => { expect(predicate).toHaveBeenCalledWith(2); }); }); + + describe("asyncSomeParallel", () => { + it("when called with an empty array, it should return false", async () => { + expect(await asyncSomeParallel([], jest.fn().mockResolvedValue(true))).toBe(false); + }); + + it("when all the predicates return false", async () => { + expect(await asyncSomeParallel([1, 2, 3], jest.fn().mockResolvedValue(false))).toBe(false); + }); + + it("when all the predicates return true", async () => { + expect(await asyncSomeParallel([1, 2, 3], jest.fn().mockResolvedValue(true))).toBe(true); + }); + + it("when one of the predicate return true", async () => { + const predicate = jest.fn().mockImplementation((value) => Promise.resolve(value === 2)); + expect(await asyncSomeParallel([1, 2, 3], predicate)).toBe(true); + }); + }); });