2024-07-08 10:57:41 +01:00
|
|
|
/*
|
2026-07-17 19:20:30 +03:00
|
|
|
Copyright 2026 hayaksi1
|
2024-09-09 14:57:16 +01:00
|
|
|
Copyright 2024 New Vector Ltd.
|
2024-07-08 10:57:41 +01:00
|
|
|
Copyright 2024 The Matrix.org Foundation C.I.C.
|
|
|
|
|
|
2025-01-06 11:18:54 +00:00
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
2024-09-09 14:57:16 +01:00
|
|
|
Please see LICENSE files in the repository root for full details.
|
2024-07-08 10:57:41 +01:00
|
|
|
*/
|
|
|
|
|
|
2026-07-17 19:20:30 +03:00
|
|
|
import { act, render } from "jest-matrix-react";
|
2024-07-08 10:57:41 +01:00
|
|
|
import React from "react";
|
2026-07-17 19:20:30 +03:00
|
|
|
import { type Room } from "matrix-js-sdk/src/matrix";
|
2024-07-08 10:57:41 +01:00
|
|
|
|
2024-10-15 14:57:26 +01:00
|
|
|
import SdkConfig from "../../../../../src/SdkConfig";
|
|
|
|
|
import SearchWarning, { WarningKind } from "../../../../../src/components/views/elements/SearchWarning";
|
2026-07-17 19:20:30 +03:00
|
|
|
import EventIndexPeg from "../../../../../src/indexing/EventIndexPeg";
|
|
|
|
|
import { type default as EventIndex } from "../../../../../src/indexing/EventIndex";
|
|
|
|
|
import { SearchScope } from "../../../../../src/Searching";
|
|
|
|
|
|
|
|
|
|
const SEARCHED_ROOM = "!searched:example.org";
|
|
|
|
|
const OTHER_ROOM = "!other:example.org";
|
|
|
|
|
const PARTIAL_WARNING = "Results may be incomplete because your search index is still being built.";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A minimal fake EventIndex exposing only the surface that SearchWarning consumes:
|
|
|
|
|
* `crawlingRooms()` (the rooms with outstanding crawler checkpoints), `isRoomIndexed()` (whether
|
|
|
|
|
* the index holds any events for a room) and the `changedCheckpoint` emitter.
|
|
|
|
|
*/
|
|
|
|
|
class FakeEventIndex {
|
|
|
|
|
private listeners = new Map<string, Set<(...args: any[]) => void>>();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param crawling Rooms with an outstanding crawler checkpoint.
|
|
|
|
|
* @param indexed Rooms the index holds events for. Defaults to `undefined`, meaning
|
|
|
|
|
* `isRoomIndexed` resolves `undefined`, as it does when there is no index manager.
|
|
|
|
|
*/
|
|
|
|
|
public constructor(
|
|
|
|
|
private crawling: string[] = [],
|
|
|
|
|
private indexed?: string[],
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
public crawlingRooms(): { crawlingRooms: Set<string>; totalRooms: Set<string> } {
|
|
|
|
|
return { crawlingRooms: new Set(this.crawling), totalRooms: new Set(this.crawling) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async isRoomIndexed(roomId: string): Promise<boolean | undefined> {
|
|
|
|
|
return this.indexed === undefined ? undefined : this.indexed.includes(roomId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public on(event: string, listener: (...args: any[]) => void): void {
|
|
|
|
|
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
|
|
|
|
this.listeners.get(event)!.add(listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public removeListener(event: string, listener: (...args: any[]) => void): void {
|
|
|
|
|
this.listeners.get(event)?.delete(listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Test helper: simulate the crawler advancing (or finishing, when `crawling` is empty).
|
|
|
|
|
*
|
|
|
|
|
* The real index emits only the globally-current room, which cannot answer a per-room
|
|
|
|
|
* question, so `currentRoom` is passed through purely to prove consumers ignore it.
|
|
|
|
|
*/
|
|
|
|
|
public emitChangedCheckpoint(crawling: string[], currentRoom: Room | null = null): void {
|
|
|
|
|
this.crawling = crawling;
|
|
|
|
|
this.listeners.get("changedCheckpoint")?.forEach((listener) => listener(currentRoom));
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-07-08 10:57:41 +01:00
|
|
|
|
|
|
|
|
describe("<SearchWarning />", () => {
|
2026-07-17 19:20:30 +03:00
|
|
|
afterEach(() => {
|
|
|
|
|
EventIndexPeg.index = null;
|
|
|
|
|
EventIndexPeg.error = undefined;
|
|
|
|
|
});
|
|
|
|
|
|
2024-07-08 10:57:41 +01:00
|
|
|
describe("with desktop builds available", () => {
|
|
|
|
|
beforeEach(() => {
|
2026-07-17 19:20:30 +03:00
|
|
|
EventIndexPeg.index = null;
|
2024-07-08 10:57:41 +01:00
|
|
|
SdkConfig.put({
|
|
|
|
|
brand: "Element",
|
|
|
|
|
desktop_builds: {
|
|
|
|
|
available: true,
|
|
|
|
|
logo: "https://logo",
|
|
|
|
|
url: "https://url",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders with a logo by default", () => {
|
2024-08-06 18:22:02 +01:00
|
|
|
const { asFragment, getByRole } = render(
|
2024-07-08 10:57:41 +01:00
|
|
|
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} />,
|
|
|
|
|
);
|
2024-10-21 14:50:06 +01:00
|
|
|
expect(getByRole("presentation")).toHaveAttribute("src", "https://logo");
|
2024-07-08 10:57:41 +01:00
|
|
|
expect(asFragment()).toMatchSnapshot();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders without a logo when showLogo=false", () => {
|
|
|
|
|
const { asFragment, queryByRole } = render(
|
|
|
|
|
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} showLogo={false} />,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(queryByRole("img")).not.toBeInTheDocument();
|
|
|
|
|
expect(asFragment()).toMatchSnapshot();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-17 19:20:30 +03:00
|
|
|
|
|
|
|
|
describe("with the event index present", () => {
|
|
|
|
|
const setIndex = (index: FakeEventIndex): void => {
|
|
|
|
|
EventIndexPeg.index = index as unknown as EventIndex;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Let the pending isRoomIndexed() lookup settle and React re-render off the back of it. */
|
|
|
|
|
const settle = async (): Promise<void> => {
|
|
|
|
|
await act(async () => {});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("warns a room-scoped search while the searched room is still being crawled", async () => {
|
|
|
|
|
setIndex(new FakeEventIndex([SEARCHED_ROOM], []));
|
|
|
|
|
|
|
|
|
|
const { queryByText, queryByRole } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
// The notice appears dynamically while the panel is open, so it must be a live region (#32253).
|
|
|
|
|
expect(queryByRole("status")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not warn a room-scoped search when only an unrelated room is still being crawled", async () => {
|
|
|
|
|
setIndex(new FakeEventIndex([OTHER_ROOM], [SEARCHED_ROOM]));
|
|
|
|
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("warns a room-scoped search when the index holds no events for the searched room", async () => {
|
|
|
|
|
// Nothing is queued for the searched room, but it has never been indexed: this is how a
|
|
|
|
|
// room looks before its checkpoint has been seeded, and the crawl set alone cannot see
|
|
|
|
|
// it. The crawler is still working through another room, so indexing is still under way.
|
|
|
|
|
setIndex(new FakeEventIndex([OTHER_ROOM], []));
|
|
|
|
|
|
|
|
|
|
const { queryByText } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not warn about an unindexed room once the crawler has drained", async () => {
|
|
|
|
|
// The index holds nothing for the room, but the crawler has no work left, so nothing
|
|
|
|
|
// more is coming: the warning would never clear itself, and its claim that the index is
|
|
|
|
|
// "still being built" would be untrue.
|
|
|
|
|
setIndex(new FakeEventIndex([], []));
|
|
|
|
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps an earned warning up while an unrelated checkpoint change is re-checked", async () => {
|
|
|
|
|
// The warning here is earned asynchronously: the room is not queued, but the index
|
|
|
|
|
// holds nothing for it. An unrelated crawler transition must not blink it off while
|
|
|
|
|
// the fresh lookup is in flight.
|
|
|
|
|
const index = new FakeEventIndex([OTHER_ROOM], []);
|
|
|
|
|
setIndex(index);
|
|
|
|
|
|
|
|
|
|
const { queryByText } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
// The crawler moves on to a third room; nothing about the searched room changed.
|
|
|
|
|
act(() => {
|
|
|
|
|
index.emitChangedCheckpoint([OTHER_ROOM, "!third:example.org"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Asserted before the lookup settles: the warning must not have been dropped.
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
await settle();
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("treats an unanswerable isRoomIndexed as no evidence for a room-scoped search", async () => {
|
|
|
|
|
setIndex(new FakeEventIndex([OTHER_ROOM], undefined));
|
|
|
|
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not carry a warning from a previously searched room across a scope change", async () => {
|
|
|
|
|
// The crawler is working through OTHER_ROOM, so an all-rooms search warns. Switching to
|
|
|
|
|
// a room-scoped search of a fully-indexed room must not leave that warning on screen
|
|
|
|
|
// while the isRoomIndexed lookup for the new room is in flight.
|
|
|
|
|
setIndex(new FakeEventIndex([OTHER_ROOM], [SEARCHED_ROOM]));
|
|
|
|
|
|
|
|
|
|
const { queryByText, rerender } = render(
|
|
|
|
|
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} scope={SearchScope.All} />,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
rerender(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Asserted before settling: the stale answer must be gone as soon as the effect for
|
|
|
|
|
// the new scope has run, rather than lingering for the isRoomIndexed round-trip.
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("warns an all-rooms search while any room is still being crawled", async () => {
|
|
|
|
|
setIndex(new FakeEventIndex([OTHER_ROOM], []));
|
|
|
|
|
|
|
|
|
|
const { queryByText } = render(
|
|
|
|
|
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} scope={SearchScope.All} />,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to the global check when the searched room is not yet known", async () => {
|
|
|
|
|
// RoomView can render before a room alias has resolved to an id.
|
|
|
|
|
setIndex(new FakeEventIndex([OTHER_ROOM], []));
|
|
|
|
|
|
|
|
|
|
const { queryByText } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={undefined}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders nothing once the index has finished crawling", async () => {
|
|
|
|
|
setIndex(new FakeEventIndex([], []));
|
|
|
|
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} scope={SearchScope.All} />,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not warn for the Files kind even while crawling", async () => {
|
|
|
|
|
setIndex(new FakeEventIndex([SEARCHED_ROOM], []));
|
|
|
|
|
|
|
|
|
|
const { container } = render(<SearchWarning isRoomEncrypted={true} kind={WarningKind.Files} />);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clears the warning when the searched room's checkpoint drains", async () => {
|
|
|
|
|
const index = new FakeEventIndex([SEARCHED_ROOM], [SEARCHED_ROOM]);
|
|
|
|
|
setIndex(index);
|
|
|
|
|
|
|
|
|
|
const { queryByText } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
index.emitChangedCheckpoint([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps warning when the crawler moves to another room but the searched room is still queued", async () => {
|
|
|
|
|
const index = new FakeEventIndex([SEARCHED_ROOM, OTHER_ROOM], [SEARCHED_ROOM]);
|
|
|
|
|
setIndex(index);
|
|
|
|
|
|
|
|
|
|
const { queryByText } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={true}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await settle();
|
|
|
|
|
|
|
|
|
|
// The event payload names a different room; the searched room is still outstanding, so
|
|
|
|
|
// the warning must survive. This fails if the handler trusts the payload.
|
|
|
|
|
await act(async () => {
|
|
|
|
|
index.emitChangedCheckpoint([SEARCHED_ROOM], { roomId: OTHER_ROOM } as Room);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders nothing when the room is not encrypted even while crawling", async () => {
|
|
|
|
|
setIndex(new FakeEventIndex([SEARCHED_ROOM], []));
|
|
|
|
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<SearchWarning
|
|
|
|
|
isRoomEncrypted={false}
|
|
|
|
|
kind={WarningKind.Search}
|
|
|
|
|
scope={SearchScope.Room}
|
|
|
|
|
roomId={SEARCHED_ROOM}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("with no event index (web build)", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
EventIndexPeg.index = null;
|
|
|
|
|
EventIndexPeg.error = undefined;
|
|
|
|
|
SdkConfig.put({
|
|
|
|
|
brand: "Element",
|
|
|
|
|
desktop_builds: {
|
|
|
|
|
available: true,
|
|
|
|
|
logo: "https://logo",
|
|
|
|
|
url: "https://url",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still renders the desktop/enable-search warning", () => {
|
|
|
|
|
const { container } = render(<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} />);
|
|
|
|
|
|
|
|
|
|
expect(container.querySelector(".mx_SearchWarning")).toBeInTheDocument();
|
|
|
|
|
expect(container.textContent).toContain("to search encrypted messages");
|
|
|
|
|
// It is the desktop/enable-search affordance, not the partial-index notice.
|
|
|
|
|
expect(container.textContent).not.toContain("your search index is still being built");
|
|
|
|
|
});
|
|
|
|
|
});
|
2024-07-08 10:57:41 +01:00
|
|
|
});
|