From 9006990e5860dccf85cfc80a0109523f1b50316e Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:27:09 -0700 Subject: [PATCH] Parallelize test-electron --- .github/workflows/ci.yml | 2 +- test/test.js | 1 + ts/scripts/test-electron.ts | 147 ++++++++++-------- ts/test-electron/SignalProtocolStore_test.ts | 5 +- .../services/MessageCache_test.ts | 6 + ts/test-electron/sql/sendLog_test.ts | 1 + .../state/ducks/conversations_test.ts | 4 +- ts/windows/main/preload_test.ts | 31 +++- 8 files changed, 127 insertions(+), 70 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf675d363d1..d34cc119188 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,7 @@ jobs: linux: needs: lint - runs-on: ubuntu-latest + runs-on: ubuntu-latest-8-cores timeout-minutes: 30 steps: diff --git a/test/test.js b/test/test.js index 1efebbc7844..421ffbab792 100644 --- a/test/test.js +++ b/test/test.js @@ -30,6 +30,7 @@ window.textsecure.storage.protocol = window.getSignalProtocolStore(); runner.on('pass', test => window.testUtilities.onTestEvent({ type: 'pass', title: test.titlePath(), + duration: test.duration, })); runner.on('fail', (test, error) => window.testUtilities.onTestEvent({ type: 'fail', diff --git a/ts/scripts/test-electron.ts b/ts/scripts/test-electron.ts index fe9fc9036bc..76738a6b55a 100644 --- a/ts/scripts/test-electron.ts +++ b/ts/scripts/test-electron.ts @@ -4,15 +4,22 @@ import { spawn } from 'node:child_process'; import { join } from 'node:path'; import { pipeline } from 'node:stream/promises'; +import { cpus, tmpdir } from 'node:os'; +import { mkdtemp, rm } from 'node:fs/promises'; import z from 'zod'; import split2 from 'split2'; import logSymbols from 'log-symbols'; import { explodePromise } from '../util/explodePromise'; import { missingCaseError } from '../util/missingCaseError'; +import { SECOND } from '../util/durations'; const ROOT_DIR = join(__dirname, '..', '..'); +const WORKER_COUNT = process.env.WORKER_COUNT + ? parseInt(process.env.WORKER_COUNT, 10) + : Math.min(8, cpus().length); + const ELECTRON = join( ROOT_DIR, 'node_modules', @@ -23,45 +30,71 @@ const ELECTRON = join( const MAX_RETRIES = 3; const RETRIABLE_SIGNALS = ['SIGBUS']; -const failSchema = z.object({ +const failureSchema = z.object({ type: z.literal('fail'), title: z.string().array(), error: z.string(), }); +type Failure = z.infer; + const eventSchema = z .object({ type: z.literal('pass'), title: z.string().array(), + duration: z.number(), }) - .or(failSchema) + .or(failureSchema) .or( z.object({ type: z.literal('end'), }) ); -async function launchElectron(attempt: number): Promise { +async function launchElectron( + worker: number, + attempt: number +): Promise<{ pass: number; failures: Array }> { if (attempt > MAX_RETRIES) { console.error(`Failed after ${MAX_RETRIES} retries, exiting.`); process.exit(1); } - console.log(`Launching electron for tests, attempt #${attempt}...`); + if (attempt !== 1) { + console.log( + `Launching electron ${worker} for tests, attempt #${attempt}...` + ); + } - const proc = spawn(ELECTRON, [ROOT_DIR, ...process.argv.slice(2)], { - cwd: ROOT_DIR, - env: { - ...process.env, - // Setting NODE_ENV to test triggers main.ts to load - // 'test/index.html' instead of 'background.html', which loads the tests - // via `test.js` - NODE_ENV: 'test', - TEST_QUIT_ON_COMPLETE: 'on', - }, - // Since we run `.cmd` file on Windows - use shell - shell: process.platform === 'win32', - }); + const storagePath = await mkdtemp(join(tmpdir(), 'signal-test-')); + + const proc = spawn( + ELECTRON, + [ + 'ci.js', + '--worker', + worker.toString(), + '--worker-count', + WORKER_COUNT.toString(), + ...process.argv.slice(2), + ], + { + cwd: ROOT_DIR, + env: { + ...process.env, + // Setting NODE_ENV to test triggers main.ts to load + // 'test/index.html' instead of 'background.html', which loads the tests + // via `test.js` + NODE_ENV: 'test', + TEST_QUIT_ON_COMPLETE: 'on', + SIGNAL_CI_CONFIG: JSON.stringify({ + storagePath, + }), + }, + // Since we run `.cmd` file on Windows - use shell + shell: process.platform === 'win32', + } + ); const { resolve, reject, promise: exitPromise } = explodePromise(); @@ -76,38 +109,8 @@ async function launchElectron(attempt: number): Promise { }); let pass = 0; - const failures = new Array>(); + const failures = new Array(); let done = false; - let stack = new Array(); - - function enter(path: ReadonlyArray): void { - // Find the first different fragment - let i: number; - for (i = 0; i < path.length - 1; i += 1) { - if (stack[i] !== path[i]) { - break; - } - } - - // Separate sections - if (i !== stack.length) { - console.log(''); - - // Remove different fragments - stack = stack.slice(0, i); - } - - for (; i < path.length - 1; i += 1) { - const fragment = path[i]; - - console.log(indent(fragment)); - stack.push(fragment); - } - } - - function indent(value: string): string { - return `${' '.repeat(stack.length)}${value}`; - } try { await Promise.all([ @@ -137,18 +140,20 @@ async function launchElectron(attempt: number): Promise { const event = eventSchema.parse(JSON.parse(match[1])); if (event.type === 'pass') { pass += 1; - enter(event.title); - console.log( - indent(`${logSymbols.success} ${event.title.at(-1)}`) - ); + process.stdout.write(logSymbols.success); + if (event.duration > SECOND) { + console.error(''); + console.error( + ` ${logSymbols.warning} ${event.title.join(' ')} ` + + `took ${event.duration}ms` + ); + } } else if (event.type === 'fail') { failures.push(event); - enter(event.title); - console.error( - indent(`${logSymbols.error} ${event.title.at(-1)}`) - ); + console.error(''); + console.error(` ${logSymbols.error} ${event.title.join(' ')}`); console.error(''); console.error(event.error); } else if (event.type === 'end') { @@ -161,15 +166,38 @@ async function launchElectron(attempt: number): Promise { ]); } catch (error) { if (exitSignal && RETRIABLE_SIGNALS.includes(exitSignal)) { - return launchElectron(attempt + 1); + return launchElectron(worker, attempt + 1); } throw error; + } finally { + try { + await rm(storagePath, { recursive: true }); + } catch { + // Ignore + } } if (!done) { throw new Error('Tests terminated early!'); } + return { pass, failures }; +} + +async function main() { + const promises = []; + for (let i = 0; i < WORKER_COUNT; i += 1) { + promises.push(launchElectron(i, 1)); + } + const results = await Promise.all(promises); + + let pass = 0; + let failures = new Array(); + for (const result of results) { + pass += result.pass; + failures = failures.concat(result.failures); + } + if (failures.length) { console.error(''); console.error('Failing tests:'); @@ -181,6 +209,7 @@ async function launchElectron(attempt: number): Promise { } } + console.log(''); console.log( `Passed ${pass} | Failed ${failures.length} | ` + `Total ${pass + failures.length}` @@ -191,10 +220,6 @@ async function launchElectron(attempt: number): Promise { } } -async function main() { - await launchElectron(1); -} - main().catch(error => { console.error(error); process.exit(1); diff --git a/ts/test-electron/SignalProtocolStore_test.ts b/ts/test-electron/SignalProtocolStore_test.ts index b293a8bc089..0c1f3de9a5b 100644 --- a/ts/test-electron/SignalProtocolStore_test.ts +++ b/ts/test-electron/SignalProtocolStore_test.ts @@ -3,8 +3,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import chai, { assert } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; +import { assert } from 'chai'; import { clone } from 'lodash'; import { Direction, @@ -38,8 +37,6 @@ import { QualifiedAddress } from '../types/QualifiedAddress'; import { generateAci, generatePni } from '../types/ServiceId'; import type { IdentityKeyType, KeyPairType } from '../textsecure/Types.d'; -chai.use(chaiAsPromised); - const { RecordStructure, SessionStructure, diff --git a/ts/test-electron/services/MessageCache_test.ts b/ts/test-electron/services/MessageCache_test.ts index 23ca61ac701..5fd8aebdfd1 100644 --- a/ts/test-electron/services/MessageCache_test.ts +++ b/ts/test-electron/services/MessageCache_test.ts @@ -12,6 +12,12 @@ import { MessageCache } from '../../services/MessageCache'; import { generateAci } from '../../types/ServiceId'; describe('MessageCache', () => { + beforeEach(async () => { + const ourAci = generateAci(); + await window.textsecure.storage.put('uuid_id', `${ourAci}.1`); + await window.ConversationController.load(); + }); + describe('filterBySentAt', () => { it('returns an empty iterable if no messages match', () => { const mc = new MessageCache(); diff --git a/ts/test-electron/sql/sendLog_test.ts b/ts/test-electron/sql/sendLog_test.ts index dffd2699de9..2bea0e3e2cb 100644 --- a/ts/test-electron/sql/sendLog_test.ts +++ b/ts/test-electron/sql/sendLog_test.ts @@ -29,6 +29,7 @@ const { describe('sql/sendLog', () => { beforeEach(async () => { await removeAllSentProtos(); + await window.ConversationController.load(); }); it('roundtrips with insertSentProto/getAllSentProtos', async () => { diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 4faca52365d..3b722a7495a 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -117,7 +117,9 @@ describe('both/state/ducks/conversations', () => { let sinonSandbox: sinon.SinonSandbox; let createGroupStub: sinon.SinonStub; - beforeEach(() => { + beforeEach(async () => { + await window.ConversationController.load(); + sinonSandbox = sinon.createSandbox(); sinonSandbox.stub(window.Whisper.events, 'trigger'); diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index 58c32f4f894..a86dae3371a 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -8,7 +8,9 @@ import { ipcRenderer as ipc } from 'electron'; import { sync } from 'fast-glob'; // eslint-disable-next-line import/no-extraneous-dependencies -import { assert, config as chaiConfig } from 'chai'; +import chai, { assert, config as chaiConfig } from 'chai'; +// eslint-disable-next-line import/no-extraneous-dependencies +import chaiAsPromised from 'chai-as-promised'; // eslint-disable-next-line import/no-extraneous-dependencies import { reporters, type MochaOptions } from 'mocha'; @@ -19,6 +21,8 @@ import { initializeRedux } from '../../state/initializeRedux'; import * as Stickers from '../../types/Stickers'; import { ThemeType } from '../../types/Util'; +chai.use(chaiAsPromised); + // Show actual objects instead of abbreviated errors chaiConfig.truncateThreshold = 0; @@ -47,6 +51,8 @@ window.assert = assert; // code in `test/test.js`. const setup: MochaOptions = {}; +let worker = 0; +let workerCount = 1; { const { values } = parseArgs({ @@ -55,6 +61,12 @@ const setup: MochaOptions = {}; grep: { type: 'string', }, + worker: { + type: 'string', + }, + 'worker-count': { + type: 'string', + }, }, strict: false, }); @@ -62,6 +74,12 @@ const setup: MochaOptions = {}; if (typeof values.grep === 'string') { setup.grep = values.grep; } + if (typeof values.worker === 'string') { + worker = parseInt(values.worker, 10); + } + if (typeof values['worker-count'] === 'string') { + workerCount = parseInt(values['worker-count'], 10); + } } window.testUtilities = { @@ -104,10 +122,17 @@ window.testUtilities = { prepareTests() { console.log('Preparing tests...'); - sync('../../test-{both,electron}/**/*_test.js', { + const files = sync('../../test-{both,electron}/**/*_test.js', { absolute: true, cwd: __dirname, - }).forEach(require); + }); + + for (let i = 0; i < files.length; i += 1) { + if (i % workerCount === worker) { + // eslint-disable-next-line import/no-dynamic-require, global-require + require(files[i]); + } + } }, };