diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 064b967bb6..6324c3823e 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -155,6 +155,21 @@ jobs: # DEBUG: 'mock:benchmarks' ARTIFACTS_DIR: artifacts/convo-open + - name: Run call history search benchmarks + run: | + set -o pipefail + rm -rf /tmp/mock + xvfb-run --auto-servernum node \ + ts/test-mock/benchmarks/call_history_search_bench.js | \ + tee benchmark-call-history-search.log + timeout-minutes: 10 + env: + NODE_ENV: production + RUN_COUNT: 100 + ELECTRON_ENABLE_STACK_DUMPING: on + # DEBUG: 'mock:benchmarks' + ARTIFACTS_DIR: artifacts/call-history-search + - name: Upload benchmark logs on failure if: failure() uses: actions/upload-artifact@v4 @@ -184,5 +199,6 @@ jobs: node ./bin/publish.js ../benchmark-large-group-send-with-blocks.log desktop.ci.performance.largeGroupSendWithBlocks node ./bin/publish.js ../benchmark-large-group-send.log desktop.ci.performance.largeGroupSend node ./bin/publish.js ../benchmark-convo-open.log desktop.ci.performance.convoOpen + node ./bin/publish.js ../benchmark-call-history-search.log desktop.ci.performance.callHistorySearch env: DD_API_KEY: ${{ secrets.DATADOG_API_KEY }} diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index 16005cbf99..3a658b2c4e 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -980,7 +980,7 @@ export function CallsList({ ref={infiniteLoaderRef} isRowLoaded={isRowLoaded} loadMoreRows={loadMoreRows} - rowCount={rowCount} + rowCount={searchState.results?.count ?? Infinity} minimumBatchSize={100} threshold={30} > diff --git a/ts/test-mock/benchmarks/call_history_search_bench.ts b/ts/test-mock/benchmarks/call_history_search_bench.ts new file mode 100644 index 0000000000..e0988c00f9 --- /dev/null +++ b/ts/test-mock/benchmarks/call_history_search_bench.ts @@ -0,0 +1,210 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { PrimaryDevice } from '@signalapp/mock-server'; +import { Proto, StorageState } from '@signalapp/mock-server'; + +import Long from 'long'; +import { sample } from 'lodash'; +import { expect } from 'playwright/test'; +import { Bootstrap, debug, RUN_COUNT, DISCARD_COUNT } from './fixtures'; +import { stats } from '../../util/benchmark/stats'; +import { uuidToBytes } from '../../util/uuidToBytes'; +import { strictAssert } from '../../util/assert'; +import { typeIntoInput } from '../helpers'; + +const CALL_HISTORY_COUNT = 1000; + +function rand(values: ReadonlyArray): T { + const value = sample(values); + strictAssert(value != null, 'must not be null'); + return value; +} + +const { CallEvent } = Proto.SyncMessage; +const { Type, Direction, Event } = CallEvent; + +const Types = [Type.AUDIO_CALL, Type.VIDEO_CALL]; +const Directions = [Direction.INCOMING, Direction.OUTGOING]; +const Events = [Event.ACCEPTED, Event.NOT_ACCEPTED]; + +Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { + const { server, contacts, phone } = bootstrap; + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + e164: phone.device.number, + givenName: phone.profileName, + readReceipts: true, + hasCompletedUsernameOnboarding: true, + }); + + debug('accepting all contacts'); + for (const contact of contacts) { + state = state.addContact(contact, { + identityKey: contact.publicKey.serialize(), + profileKey: contact.profileKey.serialize(), + whitelisted: true, + }); + } + await phone.setStorageState(state); + + debug('linking'); + const app = await bootstrap.link(); + const { desktop } = bootstrap; + + debug('sending messages from all contacts'); + await Promise.all( + contacts.map(async contact => { + const timestamp = bootstrap.getTimestamp(); + + await server.send( + desktop, + await contact.encryptText( + desktop, + `hello from: ${contact.profileName}`, + { timestamp, sealed: true } + ) + ); + + await server.send( + desktop, + await phone.encryptSyncRead(desktop, { + timestamp: bootstrap.getTimestamp(), + messages: [ + { + senderAci: contact.device.aci, + timestamp, + }, + ], + }) + ); + }) + ); + + async function sendCallEventSync( + contact: PrimaryDevice, + type: Proto.SyncMessage.CallEvent.Type, + direction: Proto.SyncMessage.CallEvent.Direction, + event: Proto.SyncMessage.CallEvent.Event, + timestamp: number + ) { + await phone.sendRaw( + desktop, + { + syncMessage: { + callEvent: { + peerId: uuidToBytes(contact.device.aci), + callId: Long.fromNumber(timestamp), + timestamp: Long.fromNumber(timestamp), + type, + direction, + event, + }, + }, + }, + { timestamp } + ); + } + + debug('sending initial call events'); + let unreadCount = 0; + await Promise.all( + Array.from({ length: CALL_HISTORY_COUNT }, () => { + const contact = rand(contacts); + const type = rand(Types); + const direction = rand(Directions); + const event = rand(Events); + const timestamp = bootstrap.getTimestamp(); + + if ( + direction === Proto.SyncMessage.CallEvent.Direction.INCOMING && + event === Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED + ) { + unreadCount += 1; + } + return sendCallEventSync(contact, type, direction, event, timestamp); + }) + ); + + const window = await app.getWindow(); + + const CallsNavTab = window.getByTestId('NavTabsItem--Calls'); + const CallsNavTabUnread = CallsNavTab.locator('.NavTabs__ItemUnreadBadge'); + const CallsTabSidebar = window.locator('.CallsTab .NavSidebar'); + const SearchBar = CallsTabSidebar.locator('.module-SearchInput__input'); + const CallListItem = CallsTabSidebar.locator('.CallsList__ItemTile'); + const CreateCallLink = CallListItem.filter({ hasText: 'Create a Call Link' }); + const CallsTabDetails = window.locator('.CallsTab__ConversationCallDetails'); + const CallsTabDetailsTitle = CallsTabDetails.locator( + '.ConversationDetailsHeader__title' + ); + + debug('waiting for unread badge to hit correct value', unreadCount); + await CallsNavTabUnread.getByText(`${unreadCount} unread`).waitFor(); + + debug('opening calls tab'); + await CallsNavTab.click(); + + async function measure(runId: number): Promise { + // setup + const searchContact = contacts[runId % contacts.length]; + const OtherCallListItems = CallListItem.filter({ + hasNotText: searchContact.profileName, + }); + const timestamp = bootstrap.getTimestamp(); + const NewCallListItemTime = window.locator( + `.CallsList__ItemCallInfo time[datetime="${new Date(timestamp).toISOString()}"]` + ); + const NewCallListItem = CallListItem.filter({ + has: NewCallListItemTime, + }); + const NewCallDetailsTitle = CallsTabDetailsTitle.filter({ + hasText: searchContact.profileName, + }); + + // measure + const start = Date.now(); + + // test + await typeIntoInput(SearchBar, searchContact.profileName); + await CreateCallLink.waitFor({ state: 'hidden' }); // hides when searching + await expect(OtherCallListItems).not.toBeAttached(); + await sendCallEventSync( + searchContact, + Type.AUDIO_CALL, + Direction.INCOMING, + Event.ACCEPTED, + timestamp + ); + await NewCallListItem.click(); + await NewCallDetailsTitle.waitFor(); + await SearchBar.clear(); + await CreateCallLink.waitFor(); + + // measure + const end = Date.now(); + const delta = end - start; + return delta; + } + + const deltaList = new Array(); + for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) { + // eslint-disable-next-line no-await-in-loop + const delta = await measure(runId); + + if (runId >= DISCARD_COUNT) { + deltaList.push(delta); + // eslint-disable-next-line no-console + console.log('run=%d info=%j', runId - DISCARD_COUNT, { delta }); + } else { + // eslint-disable-next-line no-console + console.log('discarded=%d info=%j', runId, { delta }); + } + } + + // eslint-disable-next-line no-console + console.log('stats info=%j', { delta: stats(deltaList, [99, 99.8]) }); +});