Parallelize test-electron

This commit is contained in:
Fedor Indutny 2024-07-22 12:27:09 -07:00 committed by GitHub
parent c64762858e
commit 9006990e58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 127 additions and 70 deletions

View file

@ -124,7 +124,7 @@ jobs:
linux: linux:
needs: lint needs: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest-8-cores
timeout-minutes: 30 timeout-minutes: 30
steps: steps:

View file

@ -30,6 +30,7 @@ window.textsecure.storage.protocol = window.getSignalProtocolStore();
runner.on('pass', test => window.testUtilities.onTestEvent({ runner.on('pass', test => window.testUtilities.onTestEvent({
type: 'pass', type: 'pass',
title: test.titlePath(), title: test.titlePath(),
duration: test.duration,
})); }));
runner.on('fail', (test, error) => window.testUtilities.onTestEvent({ runner.on('fail', (test, error) => window.testUtilities.onTestEvent({
type: 'fail', type: 'fail',

View file

@ -4,15 +4,22 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { join } from 'node:path'; import { join } from 'node:path';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { cpus, tmpdir } from 'node:os';
import { mkdtemp, rm } from 'node:fs/promises';
import z from 'zod'; import z from 'zod';
import split2 from 'split2'; import split2 from 'split2';
import logSymbols from 'log-symbols'; import logSymbols from 'log-symbols';
import { explodePromise } from '../util/explodePromise'; import { explodePromise } from '../util/explodePromise';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { SECOND } from '../util/durations';
const ROOT_DIR = join(__dirname, '..', '..'); 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( const ELECTRON = join(
ROOT_DIR, ROOT_DIR,
'node_modules', 'node_modules',
@ -23,45 +30,71 @@ const ELECTRON = join(
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRIABLE_SIGNALS = ['SIGBUS']; const RETRIABLE_SIGNALS = ['SIGBUS'];
const failSchema = z.object({ const failureSchema = z.object({
type: z.literal('fail'), type: z.literal('fail'),
title: z.string().array(), title: z.string().array(),
error: z.string(), error: z.string(),
}); });
type Failure = z.infer<typeof failureSchema>;
const eventSchema = z const eventSchema = z
.object({ .object({
type: z.literal('pass'), type: z.literal('pass'),
title: z.string().array(), title: z.string().array(),
duration: z.number(),
}) })
.or(failSchema) .or(failureSchema)
.or( .or(
z.object({ z.object({
type: z.literal('end'), type: z.literal('end'),
}) })
); );
async function launchElectron(attempt: number): Promise<void> { async function launchElectron(
worker: number,
attempt: number
): Promise<{ pass: number; failures: Array<Failure> }> {
if (attempt > MAX_RETRIES) { if (attempt > MAX_RETRIES) {
console.error(`Failed after ${MAX_RETRIES} retries, exiting.`); console.error(`Failed after ${MAX_RETRIES} retries, exiting.`);
process.exit(1); 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)], { const storagePath = await mkdtemp(join(tmpdir(), 'signal-test-'));
cwd: ROOT_DIR,
env: { const proc = spawn(
...process.env, ELECTRON,
// Setting NODE_ENV to test triggers main.ts to load [
// 'test/index.html' instead of 'background.html', which loads the tests 'ci.js',
// via `test.js` '--worker',
NODE_ENV: 'test', worker.toString(),
TEST_QUIT_ON_COMPLETE: 'on', '--worker-count',
}, WORKER_COUNT.toString(),
// Since we run `.cmd` file on Windows - use shell ...process.argv.slice(2),
shell: process.platform === 'win32', ],
}); {
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<void>(); const { resolve, reject, promise: exitPromise } = explodePromise<void>();
@ -76,38 +109,8 @@ async function launchElectron(attempt: number): Promise<void> {
}); });
let pass = 0; let pass = 0;
const failures = new Array<z.infer<typeof failSchema>>(); const failures = new Array<Failure>();
let done = false; let done = false;
let stack = new Array<string>();
function enter(path: ReadonlyArray<string>): 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 { try {
await Promise.all([ await Promise.all([
@ -137,18 +140,20 @@ async function launchElectron(attempt: number): Promise<void> {
const event = eventSchema.parse(JSON.parse(match[1])); const event = eventSchema.parse(JSON.parse(match[1]));
if (event.type === 'pass') { if (event.type === 'pass') {
pass += 1; pass += 1;
enter(event.title);
console.log( process.stdout.write(logSymbols.success);
indent(`${logSymbols.success} ${event.title.at(-1)}`) if (event.duration > SECOND) {
); console.error('');
console.error(
` ${logSymbols.warning} ${event.title.join(' ')} ` +
`took ${event.duration}ms`
);
}
} else if (event.type === 'fail') { } else if (event.type === 'fail') {
failures.push(event); failures.push(event);
enter(event.title);
console.error( console.error('');
indent(`${logSymbols.error} ${event.title.at(-1)}`) console.error(` ${logSymbols.error} ${event.title.join(' ')}`);
);
console.error(''); console.error('');
console.error(event.error); console.error(event.error);
} else if (event.type === 'end') { } else if (event.type === 'end') {
@ -161,15 +166,38 @@ async function launchElectron(attempt: number): Promise<void> {
]); ]);
} catch (error) { } catch (error) {
if (exitSignal && RETRIABLE_SIGNALS.includes(exitSignal)) { if (exitSignal && RETRIABLE_SIGNALS.includes(exitSignal)) {
return launchElectron(attempt + 1); return launchElectron(worker, attempt + 1);
} }
throw error; throw error;
} finally {
try {
await rm(storagePath, { recursive: true });
} catch {
// Ignore
}
} }
if (!done) { if (!done) {
throw new Error('Tests terminated early!'); 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<Failure>();
for (const result of results) {
pass += result.pass;
failures = failures.concat(result.failures);
}
if (failures.length) { if (failures.length) {
console.error(''); console.error('');
console.error('Failing tests:'); console.error('Failing tests:');
@ -181,6 +209,7 @@ async function launchElectron(attempt: number): Promise<void> {
} }
} }
console.log('');
console.log( console.log(
`Passed ${pass} | Failed ${failures.length} | ` + `Passed ${pass} | Failed ${failures.length} | ` +
`Total ${pass + failures.length}` `Total ${pass + failures.length}`
@ -191,10 +220,6 @@ async function launchElectron(attempt: number): Promise<void> {
} }
} }
async function main() {
await launchElectron(1);
}
main().catch(error => { main().catch(error => {
console.error(error); console.error(error);
process.exit(1); process.exit(1);

View file

@ -3,8 +3,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import chai, { assert } from 'chai'; import { assert } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { clone } from 'lodash'; import { clone } from 'lodash';
import { import {
Direction, Direction,
@ -38,8 +37,6 @@ import { QualifiedAddress } from '../types/QualifiedAddress';
import { generateAci, generatePni } from '../types/ServiceId'; import { generateAci, generatePni } from '../types/ServiceId';
import type { IdentityKeyType, KeyPairType } from '../textsecure/Types.d'; import type { IdentityKeyType, KeyPairType } from '../textsecure/Types.d';
chai.use(chaiAsPromised);
const { const {
RecordStructure, RecordStructure,
SessionStructure, SessionStructure,

View file

@ -12,6 +12,12 @@ import { MessageCache } from '../../services/MessageCache';
import { generateAci } from '../../types/ServiceId'; import { generateAci } from '../../types/ServiceId';
describe('MessageCache', () => { describe('MessageCache', () => {
beforeEach(async () => {
const ourAci = generateAci();
await window.textsecure.storage.put('uuid_id', `${ourAci}.1`);
await window.ConversationController.load();
});
describe('filterBySentAt', () => { describe('filterBySentAt', () => {
it('returns an empty iterable if no messages match', () => { it('returns an empty iterable if no messages match', () => {
const mc = new MessageCache(); const mc = new MessageCache();

View file

@ -29,6 +29,7 @@ const {
describe('sql/sendLog', () => { describe('sql/sendLog', () => {
beforeEach(async () => { beforeEach(async () => {
await removeAllSentProtos(); await removeAllSentProtos();
await window.ConversationController.load();
}); });
it('roundtrips with insertSentProto/getAllSentProtos', async () => { it('roundtrips with insertSentProto/getAllSentProtos', async () => {

View file

@ -117,7 +117,9 @@ describe('both/state/ducks/conversations', () => {
let sinonSandbox: sinon.SinonSandbox; let sinonSandbox: sinon.SinonSandbox;
let createGroupStub: sinon.SinonStub; let createGroupStub: sinon.SinonStub;
beforeEach(() => { beforeEach(async () => {
await window.ConversationController.load();
sinonSandbox = sinon.createSandbox(); sinonSandbox = sinon.createSandbox();
sinonSandbox.stub(window.Whisper.events, 'trigger'); sinonSandbox.stub(window.Whisper.events, 'trigger');

View file

@ -8,7 +8,9 @@ import { ipcRenderer as ipc } from 'electron';
import { sync } from 'fast-glob'; import { sync } from 'fast-glob';
// eslint-disable-next-line import/no-extraneous-dependencies // 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 // eslint-disable-next-line import/no-extraneous-dependencies
import { reporters, type MochaOptions } from 'mocha'; import { reporters, type MochaOptions } from 'mocha';
@ -19,6 +21,8 @@ import { initializeRedux } from '../../state/initializeRedux';
import * as Stickers from '../../types/Stickers'; import * as Stickers from '../../types/Stickers';
import { ThemeType } from '../../types/Util'; import { ThemeType } from '../../types/Util';
chai.use(chaiAsPromised);
// Show actual objects instead of abbreviated errors // Show actual objects instead of abbreviated errors
chaiConfig.truncateThreshold = 0; chaiConfig.truncateThreshold = 0;
@ -47,6 +51,8 @@ window.assert = assert;
// code in `test/test.js`. // code in `test/test.js`.
const setup: MochaOptions = {}; const setup: MochaOptions = {};
let worker = 0;
let workerCount = 1;
{ {
const { values } = parseArgs({ const { values } = parseArgs({
@ -55,6 +61,12 @@ const setup: MochaOptions = {};
grep: { grep: {
type: 'string', type: 'string',
}, },
worker: {
type: 'string',
},
'worker-count': {
type: 'string',
},
}, },
strict: false, strict: false,
}); });
@ -62,6 +74,12 @@ const setup: MochaOptions = {};
if (typeof values.grep === 'string') { if (typeof values.grep === 'string') {
setup.grep = values.grep; 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 = { window.testUtilities = {
@ -104,10 +122,17 @@ window.testUtilities = {
prepareTests() { prepareTests() {
console.log('Preparing tests...'); console.log('Preparing tests...');
sync('../../test-{both,electron}/**/*_test.js', { const files = sync('../../test-{both,electron}/**/*_test.js', {
absolute: true, absolute: true,
cwd: __dirname, 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]);
}
}
}, },
}; };