Faster CI runs

This commit is contained in:
Fedor Indutny 2025-08-07 10:33:21 -07:00 committed by GitHub
commit ec8d6a7359
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 242 additions and 207 deletions

View file

@ -14,6 +14,52 @@ on:
jobs: jobs:
linux: linux:
strategy:
matrix:
metric:
- startup
- send
- groupSend
- largeGroupSendWithBlocks
- largeGroupSend
- convoOpen
- callHistorySearch
- backup
include:
- metric: startup
script: ts/test-mock/benchmarks/startup_bench.js
runCount: 10
- metric: send
script: ts/test-mock/benchmarks/send_bench.js
runCount: 100
- metric: groupSend
script: ts/test-mock/benchmarks/group_send_bench.js
runCount: 100
conversationSize: 500
- metric: largeGroupSendWithBlocks
script: ts/test-mock/benchmarks/group_send_bench.js
runCount: 50
conversationSize: 500
groupSize: 500
contactCount: 500
blockedCount: 10
discardCount: 2
- metric: largeGroupSend
script: ts/test-mock/benchmarks/group_send_bench.js
runCount: 20
conversationSize: 50
groupSize: 500
contactCount: 500
discardCount: 2
- metric: convoOpen
script: ts/test-mock/benchmarks/group_send_bench.js
runCount: 100
- metric: callHistorySearch
script: ts/test-mock/benchmarks/call_history_search_bench.js
runCount: 100
- metric: backup
script: ts/test-mock/benchmarks/backup_bench.js
runs-on: ubuntu-22.04-8-cores runs-on: ubuntu-22.04-8-cores
if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' && (!github.event.schedule || github.ref == 'refs/heads/main') }} if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' && (!github.event.schedule || github.ref == 'refs/heads/main') }}
timeout-minutes: 30 timeout-minutes: 30
@ -72,132 +118,22 @@ jobs:
run: | run: |
echo "MAX_CYCLES=2" >> "$GITHUB_ENV" echo "MAX_CYCLES=2" >> "$GITHUB_ENV"
- name: Run startup benchmarks - name: Run ${{ matrix.metric }}
run: | run: |
set -o pipefail set -o pipefail
xvfb-run --auto-servernum node ts/test-mock/benchmarks/startup_bench.js | xvfb-run --auto-servernum node ${{ matrix.script }} | tee benchmark.log
tee benchmark-startup.log
timeout-minutes: 10 timeout-minutes: 10
env: env:
NODE_ENV: production NODE_ENV: production
RUN_COUNT: 10
ELECTRON_ENABLE_STACK_DUMPING: on ELECTRON_ENABLE_STACK_DUMPING: on
DEBUG: 'mock:benchmarks' DEBUG: 'mock:benchmarks'
ARTIFACTS_DIR: artifacts/startup ARTIFACTS_DIR: artifacts/${{ matrix.metric }}
GROUP_SIZE: ${{ matrix.groupSize }}
- name: Run send benchmarks CONTACT_COUNT: ${{ matrix.contactCount }}
run: | BLOCKED_COUNT: ${{ matrix.blockedCount }}
set -o pipefail DISCARD_COUNT: ${{ matrix.discardCount }}
rm -rf /tmp/mock RUN_COUNT: ${{ matrix.runCount }}
xvfb-run --auto-servernum node ts/test-mock/benchmarks/send_bench.js | CONVERSATION_SIZE: ${{ matrix.conversationSize }}
tee benchmark-send.log
timeout-minutes: 10
env:
NODE_ENV: production
RUN_COUNT: 100
ELECTRON_ENABLE_STACK_DUMPING: on
# DEBUG: 'mock:benchmarks'
ARTIFACTS_DIR: artifacts/send
- name: Run group send benchmarks
run: |
set -o pipefail
rm -rf /tmp/mock
xvfb-run --auto-servernum node \
ts/test-mock/benchmarks/group_send_bench.js | \
tee benchmark-group-send.log
timeout-minutes: 10
env:
NODE_ENV: production
RUN_COUNT: 100
CONVERSATION_SIZE: 500
ELECTRON_ENABLE_STACK_DUMPING: on
# DEBUG: 'mock:benchmarks'
ARTIFACTS_DIR: artifacts/group-send
- name: Run large group send benchmarks with blocks
run: |
set -o pipefail
rm -rf /tmp/mock
xvfb-run --auto-servernum node \
ts/test-mock/benchmarks/group_send_bench.js | \
tee benchmark-large-group-send-with-blocks.log
timeout-minutes: 10
env:
NODE_ENV: production
GROUP_SIZE: 500
CONTACT_COUNT: 500
BLOCKED_COUNT: 10
DISCARD_COUNT: 2
RUN_COUNT: 50
CONVERSATION_SIZE: 500
ELECTRON_ENABLE_STACK_DUMPING: on
# DEBUG: 'mock:benchmarks'
ARTIFACTS_DIR: artifacts/group-send
- name: Run large group send benchmarks with delivery receipts
run: |
set -o pipefail
rm -rf /tmp/mock
xvfb-run --auto-servernum node \
ts/test-mock/benchmarks/group_send_bench.js | \
tee benchmark-large-group-send.log
timeout-minutes: 10
env:
NODE_ENV: production
GROUP_SIZE: 500
CONTACT_COUNT: 500
GROUP_DELIVERY_RECEIPTS: 500
DISCARD_COUNT: 2
RUN_COUNT: 20
CONVERSATION_SIZE: 50
ELECTRON_ENABLE_STACK_DUMPING: on
# DEBUG: 'mock:benchmarks'
ARTIFACTS_DIR: artifacts/large-group-send
- name: Run conversation open benchmarks
run: |
set -o pipefail
rm -rf /tmp/mock
xvfb-run --auto-servernum node \
ts/test-mock/benchmarks/convo_open_bench.js | \
tee benchmark-convo-open.log
timeout-minutes: 10
env:
NODE_ENV: production
RUN_COUNT: 100
ELECTRON_ENABLE_STACK_DUMPING: on
# 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: Run backup benchmarks
run: |
set -o pipefail
rm -rf /tmp/mock
xvfb-run --auto-servernum node \
ts/test-mock/benchmarks/backup_bench.js | \
tee benchmark-backup.log
timeout-minutes: 10
env:
NODE_ENV: production
ELECTRON_ENABLE_STACK_DUMPING: on
# DEBUG: 'mock:benchmarks'
ARTIFACTS_DIR: artifacts/backup-bench
- name: Upload benchmark logs on failure - name: Upload benchmark logs on failure
if: failure() if: failure()
@ -222,13 +158,6 @@ jobs:
- name: Publish to DataDog - name: Publish to DataDog
working-directory: benchmark-results working-directory: benchmark-results
run: | run: |
node ./bin/publish.js ../benchmark-startup.log desktop.ci.performance.startup node ./bin/publish.js ../benchmark.log destop.ci.performance.${{ matrix.metric }}
node ./bin/publish.js ../benchmark-send.log desktop.ci.performance.send
node ./bin/publish.js ../benchmark-group-send.log desktop.ci.performance.groupSend
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
node ./bin/publish.js ../benchmark-backup.log desktop.ci.performance.backup
env: env:
DD_API_KEY: ${{ secrets.DATADOG_API_KEY }} DD_API_KEY: ${{ secrets.DATADOG_API_KEY }}

View file

@ -370,6 +370,11 @@ jobs:
mock-tests: mock-tests:
needs: lint needs: lint
strategy:
matrix:
workerIndex: [0, 1, 2, 3]
runs-on: ubuntu-22.04-8-cores runs-on: ubuntu-22.04-8-cores
if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' }} if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' }}
timeout-minutes: 30 timeout-minutes: 30
@ -429,12 +434,24 @@ jobs:
run: | run: |
set -o pipefail set -o pipefail
xvfb-run --auto-servernum pnpm run test-mock xvfb-run --auto-servernum pnpm run test-mock
timeout-minutes: 15
env:
NODE_ENV: production
DEBUG: mock:test:*
ARTIFACTS_DIR: artifacts/mock
WORKER_INDEX: ${{ matrix.workerIndex }}
WORKER_COUNT: 4
- name: Run docker mock server tests
if: ${{ matrix.workerIndex == 0 }}
run: |
set -o pipefail
xvfb-run --auto-servernum pnpm run test-mock-docker xvfb-run --auto-servernum pnpm run test-mock-docker
timeout-minutes: 15 timeout-minutes: 15
env: env:
NODE_ENV: production NODE_ENV: production
DEBUG: mock:test:* DEBUG: mock:test:*
ARTIFACTS_DIR: artifacts/startup ARTIFACTS_DIR: artifacts/mock-docker
- name: Upload mock server test logs on failure - name: Upload mock server test logs on failure
if: failure() if: failure()

View file

@ -49,7 +49,7 @@
"test-electron": "node ts/scripts/test-electron.js", "test-electron": "node ts/scripts/test-electron.js",
"test-release": "node ts/scripts/test-release.js", "test-release": "node ts/scripts/test-release.js",
"test-node": "cross-env LANG=en-us electron-mocha --timeout 10000 --main test/fix-linux-gtk.js --file test/setup-test-node.js --recursive ts/test-node", "test-node": "cross-env LANG=en-us electron-mocha --timeout 10000 --main test/fix-linux-gtk.js --file test/setup-test-node.js --recursive ts/test-node",
"test-mock": "mocha --require ts/test-mock/setup-ci.js ts/test-mock/**/*_test.js", "test-mock": "node ts/scripts/mocha-separator.js --require ts/test-mock/setup-ci.js -- ts/test-mock/**/*_test.js",
"test-mock-docker": "mocha --require ts/test-mock/setup-ci.js ts/test-mock/**/*_test.docker.js", "test-mock-docker": "mocha --require ts/test-mock/setup-ci.js ts/test-mock/**/*_test.docker.js",
"test-eslint": "mocha .eslint/rules/**/*.test.js --ignore-leaks", "test-eslint": "mocha .eslint/rules/**/*.test.js --ignore-leaks",
"test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test", "test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test",
@ -336,7 +336,7 @@
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"p-limit": "3.1.0", "p-limit": "3.1.0",
"pixelmatch": "5.3.0", "pixelmatch": "5.3.0",
"playwright": "1.45.0", "playwright": "1.54.2",
"pngjs": "7.0.0", "pngjs": "7.0.0",
"postcss": "8.5.3", "postcss": "8.5.3",
"postcss-loader": "8.1.1", "postcss-loader": "8.1.1",

28
pnpm-lock.yaml generated
View file

@ -745,8 +745,8 @@ importers:
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
playwright: playwright:
specifier: 1.45.0 specifier: 1.54.2
version: 1.45.0 version: 1.54.2
pngjs: pngjs:
specifier: 7.0.0 specifier: 7.0.0
version: 7.0.0 version: 7.0.0
@ -8578,18 +8578,18 @@ packages:
resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
playwright-core@1.45.0:
resolution: {integrity: sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==}
engines: {node: '>=18'}
hasBin: true
playwright-core@1.50.1: playwright-core@1.50.1:
resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==} resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
playwright@1.45.0: playwright-core@1.54.2:
resolution: {integrity: sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==} resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.54.2:
resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
@ -14312,7 +14312,7 @@ snapshots:
jest-serializer-html: 7.1.0 jest-serializer-html: 7.1.0
jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@20.17.6)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@20.17.6)(typescript@5.6.3))) jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@20.17.6)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@20.17.6)(typescript@5.6.3)))
nyc: 15.1.0 nyc: 15.1.0
playwright: 1.45.0 playwright: 1.54.2
storybook: 8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10) storybook: 8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10)
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@swc/helpers'
@ -20302,13 +20302,13 @@ snapshots:
dependencies: dependencies:
find-up: 6.3.0 find-up: 6.3.0
playwright-core@1.45.0: {}
playwright-core@1.50.1: {} playwright-core@1.50.1: {}
playwright@1.45.0: playwright-core@1.54.2: {}
playwright@1.54.2:
dependencies: dependencies:
playwright-core: 1.45.0 playwright-core: 1.54.2
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2

View file

@ -61,6 +61,11 @@ const fn = script.runInThisContext({
// See `ts/scripts/generate-preload-cache.ts` // See `ts/scripts/generate-preload-cache.ts`
if (process.env.GENERATE_PRELOAD_CACHE) { if (process.env.GENERATE_PRELOAD_CACHE) {
// Use hottest cache possible in CI
if (process.env.CI) {
fn(require, __dirname);
window.startApp();
}
writeFileSync(cachePath, script.createCachedData()); writeFileSync(cachePath, script.createCachedData());
ipcRenderer.send('shutdown'); ipcRenderer.send('shutdown');
} else { } else {

View file

@ -351,6 +351,16 @@ export class ConversationController {
if (isGroupV1(conversation.attributes)) { if (isGroupV1(conversation.attributes)) {
maybeDeriveGroupV2Id(conversation); maybeDeriveGroupV2Id(conversation);
} }
// If conversation does not have pre-existing storageID and is not our
// own (that we create on link), it might need to be uploaded to storage
// service.
if (conversation.attributes.storageID == null) {
StorageService.storageServiceUploadJob({
reason: 'new conversation',
});
}
await saveConversation(conversation.attributes); await saveConversation(conversation.attributes);
} catch (error) { } catch (error) {
log.error( log.error(
@ -1221,11 +1231,15 @@ export class ConversationController {
log.warn(`${logId}: Update messages table`); log.warn(`${logId}: Update messages table`);
await migrateConversationMessages(obsoleteId, currentId); await migrateConversationMessages(obsoleteId, currentId);
log.warn(`${logId}: Emit refreshConversation event to close old/open new`); if (
window.Whisper.events.trigger('refreshConversation', { window.reduxStore.getState().conversations.selectedConversationId ===
newId: currentId, obsoleteId
oldId: obsoleteId, ) {
}); log.warn(`${logId}: opening new conversation`);
window.reduxActions.conversations.showConversation({
conversationId: currentId,
});
}
log.warn( log.warn(
`${logId}: Eliminate old conversation from ConversationController lookups` `${logId}: Eliminate old conversation from ConversationController lookups`
@ -1236,8 +1250,10 @@ export class ConversationController {
current.captureChange('combineConversations'); current.captureChange('combineConversations');
drop(current.updateLastMessage()); drop(current.updateLastMessage());
const state = window.reduxStore.getState(); if (
if (state.conversations.selectedConversationId === current.id) { window.reduxStore.getState().conversations.selectedConversationId ===
current.id
) {
// TODO: DESKTOP-4807 // TODO: DESKTOP-4807
drop(current.loadNewestMessages(undefined, undefined)); drop(current.loadNewestMessages(undefined, undefined));
} }

View file

@ -1457,6 +1457,7 @@ export async function startApp(): Promise<void> {
await StorageService.runStorageServiceSyncJob({ await StorageService.runStorageServiceSyncJob({
reason: andSync, reason: andSync,
}); });
StorageService.runStorageServiceSyncJob.flush();
} }
} }

View file

@ -318,14 +318,15 @@ export function ConversationList({
); );
const renderRow: ListRowRenderer = useCallback( const renderRow: ListRowRenderer = useCallback(
({ key, index, style }) => { ({ key: providedKey, index, style }) => {
const row = getRow(index); const row = getRow(index);
if (!row) { if (!row) {
assertDev(false, `Expected a row at index ${index}`); assertDev(false, `Expected a row at index ${index}`);
return <div key={key} style={style} />; return <div key={providedKey} style={style} />;
} }
let result: ReactNode; let result: ReactNode;
let key: string;
switch (row.type) { switch (row.type) {
case RowType.ArchiveButton: case RowType.ArchiveButton:
result = ( result = (
@ -344,9 +345,11 @@ export function ConversationList({
</span> </span>
</button> </button>
); );
key = 'archive';
break; break;
case RowType.Blank: case RowType.Blank:
result = undefined; result = undefined;
key = `blank:${providedKey}`;
break; break;
case RowType.Contact: { case RowType.Contact: {
const { isClickable = true, hasContextMenu = false } = row; const { isClickable = true, hasContextMenu = false } = row;
@ -368,6 +371,7 @@ export function ConversationList({
onRemove={isClickable ? removeConversation : undefined} onRemove={isClickable ? removeConversation : undefined}
/> />
); );
key = `contact:${row.contact.id}`;
break; break;
} }
case RowType.ContactCheckbox: case RowType.ContactCheckbox:
@ -382,6 +386,7 @@ export function ConversationList({
theme={theme} theme={theme}
/> />
); );
key = `contact-checkbox:${row.contact.id}`;
break; break;
case RowType.ClearFilterButton: case RowType.ClearFilterButton:
result = ( result = (
@ -400,6 +405,7 @@ export function ConversationList({
</Button> </Button>
</div> </div>
); );
key = 'clear-filter';
break; break;
case RowType.PhoneNumberCheckbox: case RowType.PhoneNumberCheckbox:
result = ( result = (
@ -419,6 +425,7 @@ export function ConversationList({
theme={theme} theme={theme}
/> />
); );
key = `phone-number-checkbox:${row.phoneNumber.e164}`;
break; break;
case RowType.UsernameCheckbox: case RowType.UsernameCheckbox:
result = ( result = (
@ -438,6 +445,7 @@ export function ConversationList({
theme={theme} theme={theme}
/> />
); );
key = `username-checkbox:${row.username}`;
break; break;
case RowType.GenericCheckbox: case RowType.GenericCheckbox:
result = ( result = (
@ -453,6 +461,7 @@ export function ConversationList({
clickable clickable
/> />
); );
key = `generic-checkbox:${providedKey}`;
break; break;
case RowType.Conversation: { case RowType.Conversation: {
const itemProps = pick(row.conversation, [ const itemProps = pick(row.conversation, [
@ -486,6 +495,7 @@ export function ConversationList({
'serviceId', 'serviceId',
]); ]);
const { badges, title, unreadCount, lastMessage } = itemProps; const { badges, title, unreadCount, lastMessage } = itemProps;
key = `conversation:${itemProps.id}`;
result = ( result = (
<ConversationListItem <ConversationListItem
{...itemProps} {...itemProps}
@ -514,6 +524,7 @@ export function ConversationList({
onClick={showChooseGroupMembers} onClick={showChooseGroupMembers}
/> />
); );
key = 'create-new-group';
break; break;
case RowType.FindByUsername: case RowType.FindByUsername:
result = ( result = (
@ -523,6 +534,7 @@ export function ConversationList({
onClick={showFindByUsername} onClick={showFindByUsername}
/> />
); );
key = 'find-by-username';
break; break;
case RowType.FindByPhoneNumber: case RowType.FindByPhoneNumber:
result = ( result = (
@ -532,6 +544,7 @@ export function ConversationList({
onClick={showFindByPhoneNumber} onClick={showFindByPhoneNumber}
/> />
); );
key = 'find-by-phonenumber';
break; break;
case RowType.Header: { case RowType.Header: {
const headerText = row.getHeaderText(i18n); const headerText = row.getHeaderText(i18n);
@ -543,16 +556,20 @@ export function ConversationList({
{headerText} {headerText}
</div> </div>
); );
key = `header:${providedKey}`;
break; break;
} }
case RowType.MessageSearchResult: case RowType.MessageSearchResult:
result = <>{renderMessageSearchResult?.(row.messageId)}</>; result = <>{renderMessageSearchResult?.(row.messageId)}</>;
key = `message-search-result:${row.messageId}`;
break; break;
case RowType.SearchResultsLoadingFakeHeader: case RowType.SearchResultsLoadingFakeHeader:
result = <SearchResultsLoadingFakeHeaderComponent />; result = <SearchResultsLoadingFakeHeaderComponent />;
key = `loading-header:${providedKey}`;
break; break;
case RowType.SearchResultsLoadingFakeRow: case RowType.SearchResultsLoadingFakeRow:
result = <SearchResultsLoadingFakeRowComponent />; result = <SearchResultsLoadingFakeRowComponent />;
key = `loading-row:${providedKey}`;
break; break;
case RowType.SelectSingleGroup: case RowType.SelectSingleGroup:
result = ( result = (
@ -562,6 +579,7 @@ export function ConversationList({
onSelectGroup={onSelectConversation} onSelectGroup={onSelectConversation}
/> />
); );
key = 'select-single-group';
break; break;
case RowType.StartNewConversation: case RowType.StartNewConversation:
result = ( result = (
@ -577,6 +595,7 @@ export function ConversationList({
showConversation={showConversation} showConversation={showConversation}
/> />
); );
key = 'start-new-conversation';
break; break;
case RowType.UsernameSearchResult: case RowType.UsernameSearchResult:
result = ( result = (
@ -592,6 +611,7 @@ export function ConversationList({
showConversation={showConversation} showConversation={showConversation}
/> />
); );
key = `username-search-result:${row.username}`;
break; break;
case RowType.EmptyResults: case RowType.EmptyResults:
result = ( result = (
@ -599,6 +619,7 @@ export function ConversationList({
{row.message} {row.message}
</div> </div>
); );
key = 'empty-results';
break; break;
default: default:
throw missingCaseError(row); throw missingCaseError(row);

View file

@ -272,7 +272,13 @@ function renderNode({
!isLinkSneaky(node.url) !isLinkSneaky(node.url)
) { ) {
return ( return (
<a key={key} className={formattingClasses} href={node.url}> <a
key={key}
className={formattingClasses}
href={node.url}
target="_blank"
rel="noreferrer"
>
{content} {content}
</a> </a>
); );

View file

@ -48,7 +48,7 @@ export const parseEnvironment = makeEnumParser(
export const isTestEnvironment = (env: Environment): boolean => export const isTestEnvironment = (env: Environment): boolean =>
env === Environment.Test; env === Environment.Test;
const isMockEnvironment = (): boolean => { export const isMockEnvironment = (): boolean => {
if (isMockTestEnvironment == null) { if (isMockTestEnvironment == null) {
log.error('Mock test environment not set'); log.error('Mock test environment not set');
} }

View file

@ -24,7 +24,9 @@ export async function commonShouldJobContinue({
} }
try { try {
await waitForOnline({ timeout: timeRemaining }); if (isDeviceLinked()) {
await waitForOnline({ timeout: timeRemaining });
}
} catch (err: unknown) { } catch (err: unknown) {
log.info("didn't come online in time, giving up"); log.info("didn't come online in time, giving up");
return false; return false;

View file

@ -39,6 +39,7 @@ async function main(): Promise<void> {
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY, WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
XAUTHORITY: process.env.XAUTHORITY, XAUTHORITY: process.env.XAUTHORITY,
CI: process.env.CI ? 'on' : undefined,
GENERATE_PRELOAD_CACHE: 'on', GENERATE_PRELOAD_CACHE: 'on',
SIGNAL_CI_CONFIG: JSON.stringify({ SIGNAL_CI_CONFIG: JSON.stringify({
storagePath, storagePath,

View file

@ -0,0 +1,27 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
const MOCHA = join(__dirname, '..', '..', 'node_modules', '.bin', 'mocha');
const WORKER_COUNT = parseInt(process.env.WORKER_COUNT || '1', 10);
const WORKER_INDEX = parseInt(process.env.WORKER_INDEX || '0', 10);
const separator = process.argv.indexOf('--');
if (separator === -1) {
throw new Error('Expected `--` separator between options and files');
}
const flags = process.argv.slice(2, separator);
const files = process.argv.slice(separator + 1);
const filteredFiles = files.filter((_file, index) => {
return index % WORKER_COUNT === WORKER_INDEX;
});
console.log(`Running on ${filteredFiles.length}/${files.length} of files`);
spawnSync(MOCHA, [...flags, ...filteredFiles], {
stdio: 'inherit',
});

View file

@ -1034,6 +1034,7 @@ export class BackupsService {
window.Whisper.events.once('storageService:syncComplete', resolve); window.Whisper.events.once('storageService:syncComplete', resolve);
runStorageServiceSyncJob({ reason }); runStorageServiceSyncJob({ reason });
runStorageServiceSyncJob.flush();
await storageService; await storageService;
} }

View file

@ -82,7 +82,9 @@ import {
getRoomIdFromRootKeyString, getRoomIdFromRootKeyString,
} from '../util/callLinksRingrtc'; } from '../util/callLinksRingrtc';
import { fromPniUuidBytesOrUntaggedString } from '../util/ServiceId'; import { fromPniUuidBytesOrUntaggedString } from '../util/ServiceId';
import { isDone as isRegistrationDone } from '../util/registration';
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue';
import { isMockEnvironment } from '../environment';
const log = createLogger('storage'); const log = createLogger('storage');
@ -2083,7 +2085,6 @@ async function upload({
if (!window.storage.get('storageKey')) { if (!window.storage.get('storageKey')) {
// requesting new keys runs the sync job which will detect the conflict // requesting new keys runs the sync job which will detect the conflict
// and re-run the upload job once we're merged and up-to-date. // and re-run the upload job once we're merged and up-to-date.
log.info(`${logId}: no storageKey, requesting new keys`);
backOff.reset(); backOff.reset();
if (window.ConversationController.areWePrimaryDevice()) { if (window.ConversationController.areWePrimaryDevice()) {
@ -2091,6 +2092,12 @@ async function upload({
return; return;
} }
if (!isRegistrationDone()) {
log.warn(`${logId}: no storageKey, unlinked`);
return;
}
log.info(`${logId}: no storageKey, requesting new keys`);
await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage()); await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage());
return; return;
@ -2267,7 +2274,7 @@ export const storageServiceUploadJob = debounce(
`upload v${window.storage.get('manifestVersion')}` `upload v${window.storage.get('manifestVersion')}`
); );
}, },
500 isMockEnvironment() ? 0 : 500
); );
export const runStorageServiceSyncJob = debounce( export const runStorageServiceSyncJob = debounce(
@ -2289,7 +2296,7 @@ export const runStorageServiceSyncJob = debounce(
) )
); );
}, },
500 isMockEnvironment() ? 0 : 500
); );
export const addPendingDelete = (item: ExtendedStorageID): void => { export const addPendingDelete = (item: ExtendedStorageID): void => {

View file

@ -4879,14 +4879,17 @@ function onConversationClosed(
): ThunkAction<void, RootStateType, unknown, ConversationUnloadedActionType> { ): ThunkAction<void, RootStateType, unknown, ConversationUnloadedActionType> {
return async dispatch => { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
// Conversation was removed due to the merge
if (!conversation) { if (!conversation) {
throw new Error('onConversationClosed: Conversation not found'); log.warn(
`onConversationClosed: Conversation ${conversationId} not found`
);
} }
const logId = `onConversationClosed/${conversation.idForLogging()}`; const logId = `onConversationClosed/${conversation?.idForLogging() ?? conversationId}`;
log.info(`${logId}: unloading due to ${reason}`); log.info(`${logId}: unloading due to ${reason}`);
if (conversation.get('draftChanged')) { if (conversation?.get('draftChanged')) {
if (conversation.hasDraft()) { if (conversation.hasDraft()) {
log.info(`${logId}: new draft info needs update`); log.info(`${logId}: new draft info needs update`);
const now = Date.now(); const now = Date.now();

View file

@ -102,18 +102,6 @@ export const SmartChatsTab = memo(function SmartChatsTab() {
}, [prevConversationId, selectedConversationId]); }, [prevConversationId, selectedConversationId]);
useEffect(() => { useEffect(() => {
function refreshConversation({
newId,
oldId,
}: {
newId: string;
oldId: string;
}) {
if (prevConversationId === oldId) {
showConversation({ conversationId: newId });
}
}
// Close current opened conversation to reload the group information once // Close current opened conversation to reload the group information once
// linked. // linked.
function unload() { function unload() {
@ -128,12 +116,10 @@ export const SmartChatsTab = memo(function SmartChatsTab() {
} }
window.Whisper.events.on('pack-install-failed', packInstallFailed); window.Whisper.events.on('pack-install-failed', packInstallFailed);
window.Whisper.events.on('refreshConversation', refreshConversation);
window.Whisper.events.on('setupAsNewDevice', unload); window.Whisper.events.on('setupAsNewDevice', unload);
return () => { return () => {
window.Whisper.events.off('pack-install-failed', packInstallFailed); window.Whisper.events.off('pack-install-failed', packInstallFailed);
window.Whisper.events.off('refreshConversation', refreshConversation);
window.Whisper.events.off('setupAsNewDevice', unload); window.Whisper.events.off('setupAsNewDevice', unload);
}; };
}, [onConversationClosed, prevConversationId, showConversation, showToast]); }, [onConversationClosed, prevConversationId, showConversation, showToast]);

View file

@ -400,17 +400,22 @@ export class Bootstrap {
await app.stageLocalBackupForImport(localBackup); await app.stageLocalBackupForImport(localBackup);
} }
debug('looking for QR code or relink button'); let gotProvisionURL = false;
const qrCode = window.locator(
'.module-InstallScreenQrCodeNotScannedStep__qr-code__code' drop(
(async () => {
try {
const relinkButton = window.locator('.LeftPaneDialog__icon--relink');
await relinkButton.waitFor();
if (gotProvisionURL) {
return;
}
await relinkButton.click();
} catch {
// Ignore, provision will fail if QR code was never generated
}
})()
); );
const relinkButton = window.locator('.LeftPaneDialog__icon--relink');
await qrCode.or(relinkButton).waitFor();
if (await relinkButton.isVisible()) {
debug('unlinked, clicking left pane button');
await relinkButton.click();
await qrCode.waitFor();
}
debug('waiting for provision'); debug('waiting for provision');
const provision = await this.server.waitForProvision(); const provision = await this.server.waitForProvision();
@ -418,6 +423,8 @@ export class Bootstrap {
debug('waiting for provision URL'); debug('waiting for provision URL');
const provisionURL = await app.waitForProvisionURL(); const provisionURL = await app.waitForProvisionURL();
gotProvisionURL = true;
debug('completing provision'); debug('completing provision');
this.#privDesktop = await provision.complete({ this.#privDesktop = await provision.complete({
provisionURL, provisionURL,

View file

@ -57,7 +57,9 @@ describe('readSync', function (this: Mocha.Suite) {
const leftPane = page.locator('#LeftPane'); const leftPane = page.locator('#LeftPane');
await leftPane await leftPane
.locator('.module-conversation-list__item--contact-or-conversation') .locator(
'.module-conversation-list__item--contact-or-conversation >> "<(˶ᵔᵕᵔ˶)>"'
)
.first() .first()
.waitFor(); .waitFor();

View file

@ -87,11 +87,13 @@ describe('messaging/relink', function (this: Mocha.Suite) {
) )
.waitFor(); .waitFor();
debug('unlinkng');
await app.unlink(); await app.unlink();
await app.waitForUnlink(); await app.waitForUnlink();
await phone.unlink(desktop); await phone.unlink(desktop);
await server.removeDevice(desktop.number, desktop.deviceId); await server.removeDevice(desktop.number, desktop.deviceId);
debug('closing');
await app.close(); await app.close();
debug('change pinned contact, identity key'); debug('change pinned contact, identity key');

View file

@ -88,9 +88,7 @@ describe('unprocessed', function (this: Mocha.Suite) {
const page = await app.getWindow(); const page = await app.getWindow();
debug('opening conversation'); debug('opening conversation');
await page await page.getByTestId(alice.device.aci).click();
.locator(`[data-testid="${alice.device.aci}"] >> "${alice.profileName}"`)
.click();
await page.locator('.module-message__text >> "hello: 4"').waitFor(); await page.locator('.module-message__text >> "hello: 4"').waitFor();
await page.locator('.module-message__text >> "hello: 5"').waitFor(); await page.locator('.module-message__text >> "hello: 5"').waitFor();

View file

@ -83,6 +83,7 @@ export class App extends EventEmitter {
snapshots: true, snapshots: true,
}); });
} }
await page?.emulateMedia({ reducedMotion: 'reduce' });
await page?.waitForLoadState('load'); await page?.waitForLoadState('load');
})(), })(),
20 * SECOND 20 * SECOND

View file

@ -110,10 +110,12 @@ describe('storage service', function (this: Mocha.Suite) {
} }
const updatedState = await phone.setStorageState( const updatedState = await phone.setStorageState(
state.addRecord({ state
type: IdentifierType.ACCOUNT, .addRecord({
record: oldAccount.record, type: IdentifierType.ACCOUNT,
}) record: oldAccount.record,
})
.updateAccount({})
); );
debug('sending fetch storage'); debug('sending fetch storage');

View file

@ -48,20 +48,21 @@ describe('storage service', function (this: Mocha.Suite) {
debug('Unpinning group via storage service'); debug('Unpinning group via storage service');
{ {
const state = await phone.expectStorageState('initial state'); const state = await phone.expectStorageState('initial state');
const newState = state.unpinGroup(group);
await phone.setStorageState(state.unpinGroup(group)); await phone.setStorageState(newState);
await phone.sendFetchStorage({ await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(), timestamp: bootstrap.getTimestamp(),
}); });
await leftPane.locator(`[data-testid="${group.id}"]`).waitFor(); await app.waitForManifestVersion(newState.version);
} }
debug('Pinning group in the app'); debug('Pinning group in the app');
{ {
const state = await phone.expectStorageState('consistency check'); const state = await phone.expectStorageState('consistency check');
const convo = leftPane.locator(`[data-testid="${group.id}"]`); const convo = leftPane.getByTestId(group.id);
await convo.click(); await convo.click();
const moreButton = conversationStack.locator( const moreButton = conversationStack.locator(

View file

@ -70,11 +70,10 @@ describe('storage service', function (this: Mocha.Suite) {
await conversationView await conversationView
.locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`) .locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`)
.click({ noWaitAfter: true }); .click();
await window await window
.locator( .getByTestId('StickerPreviewModal')
'.module-sticker-manager__preview-modal__footer--install button >> "Install"' .getByRole('button', { name: 'Install' })
)
.click(); .click();
debug('waiting for sync message'); debug('waiting for sync message');
@ -114,12 +113,10 @@ describe('storage service', function (this: Mocha.Suite) {
await conversationView await conversationView
.locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`) .locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`)
.click({ noWaitAfter: true }); .click();
await window await window
.locator( .getByTestId('StickerPreviewModal')
'.module-sticker-manager__preview-modal__footer--install button ' + .getByRole('button', { name: 'Uninstall' })
'>> "Uninstall"'
)
.click(); .click();
// Confirm // Confirm

View file

@ -28,6 +28,7 @@ import type {
import createTaskWithTimeout from './TaskWithTimeout'; import createTaskWithTimeout from './TaskWithTimeout';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { isMockEnvironment } from '../environment';
import { senderCertificateService } from '../services/senderCertificate'; import { senderCertificateService } from '../services/senderCertificate';
import { backupsService } from '../services/backups'; import { backupsService } from '../services/backups';
import { import {
@ -96,7 +97,9 @@ const LAST_RESORT_KEY_UPDATE_TIME_KEY: StorageKeyByServiceIdKind = {
}; };
const PRE_KEY_ARCHIVE_AGE = 90 * DAY; const PRE_KEY_ARCHIVE_AGE = 90 * DAY;
const PRE_KEY_GEN_BATCH_SIZE = 100; // Use 20 keys for mock tests which is above the minimum, but takes much less
// time to generate and store in the database (especially for PQ keys)
const PRE_KEY_GEN_BATCH_SIZE = isMockEnvironment() ? 20 : 100;
const PRE_KEY_MAX_COUNT = 200; const PRE_KEY_MAX_COUNT = 200;
const PRE_KEY_ID_KEY: StorageKeyByServiceIdKind = { const PRE_KEY_ID_KEY: StorageKeyByServiceIdKind = {
[ServiceIdKind.ACI]: 'maxPreKeyId', [ServiceIdKind.ACI]: 'maxPreKeyId',