// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import assert from 'assert'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import createDebug from 'debug'; import type { Device, PrimaryDevice } from '@signalapp/mock-server'; import { Server, loadCertificates } from '@signalapp/mock-server'; import { App } from './playwright'; import * as durations from '../util/durations'; const debug = createDebug('mock:bootstrap'); const ELECTRON = path.join( __dirname, '..', '..', 'node_modules', '.bin', 'electron' ); const CI_SCRIPT = path.join(__dirname, '..', '..', 'ci.js'); const CLOSE_TIMEOUT = 10 * 1000; const CONTACT_FIRST_NAMES = [ 'Alice', 'Bob', 'Charlie', 'Paul', 'Steve', 'William', ]; const CONTACT_LAST_NAMES = [ 'Smith', 'Brown', 'Jones', 'Miller', 'Davis', 'Lopez', 'Gonazales', ]; const CONTACT_NAMES = new Array(); for (const firstName of CONTACT_FIRST_NAMES) { for (const lastName of CONTACT_LAST_NAMES) { CONTACT_NAMES.push(`${firstName} ${lastName}`); } } const MAX_CONTACTS = CONTACT_NAMES.length; export type BootstrapOptions = Readonly<{ extraConfig?: Record; benchmark?: boolean; linkedDevices?: number; contactCount?: number; }>; type BootstrapInternalOptions = Pick & Readonly<{ benchmark: boolean; linkedDevices: number; contactCount: number; }>; // // Bootstrap is a class that prepares mock server and desktop for running // tests/benchmarks. // // In general, the usage pattern is: // // const bootstrap = new Bootstrap(); // await bootstrap.init(); // const app = await bootstrap.link(); // await bootstrap.teardown(); // // Once initialized `bootstrap` variable will have following useful properties: // // - `server` - a mock server instance // - `desktop` - a linked device representing currently running desktop instance // - `phone` - a primary device representing desktop's primary // - `contacts` - a list of primary devices for contacts that are synced over // through contact sync // // `bootstrap.getTimestamp()` could be used to generate consecutive timestamp // for sending messages. // // All phone numbers and uuids for all contacts and ourselves are random and not // the same between different test runs. // export class Bootstrap { public readonly server = new Server(); private readonly options: BootstrapInternalOptions; private privContacts?: ReadonlyArray; private privPhone?: PrimaryDevice; private privDesktop?: Device; private storagePath?: string; private timestamp: number = Date.now() - durations.MONTH; constructor(options: BootstrapOptions = {}) { this.options = { linkedDevices: 5, contactCount: MAX_CONTACTS, benchmark: false, ...options, }; assert(this.options.contactCount <= MAX_CONTACTS); } public async init(): Promise { debug('initializing'); await this.server.listen(0); const { port } = this.server.address(); debug('started server on port=%d', port); const contactNames = CONTACT_NAMES.slice(0, this.options.contactCount); this.privContacts = await Promise.all( contactNames.map(async profileName => { const primary = await this.server.createPrimaryDevice({ profileName }); for (let i = 0; i < this.options.linkedDevices; i += 1) { // eslint-disable-next-line no-await-in-loop await this.server.createSecondaryDevice(primary); } return primary; }) ); this.privPhone = await this.server.createPrimaryDevice({ profileName: 'Mock', contacts: this.contacts, }); this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-')); debug('setting storage path=%j', this.storagePath); } public async teardown(): Promise { debug('tearing down'); await Promise.race([ this.storagePath ? fs.rm(this.storagePath, { recursive: true }) : Promise.resolve(), this.server.close(), new Promise(resolve => setTimeout(resolve, CLOSE_TIMEOUT).unref()), ]); } public async link(): Promise { debug('linking'); const app = await this.startApp(); const provision = await this.server.waitForProvision(); const provisionURL = await app.waitForProvisionURL(); this.privDesktop = await provision.complete({ provisionURL, primaryDevice: this.phone, }); debug('new desktop device %j', this.desktop.debugId); const desktopKey = await this.desktop.popSingleUseKey(); await this.phone.addSingleUseKey(this.desktop, desktopKey); for (const contact of this.contacts) { // eslint-disable-next-line no-await-in-loop const contactKey = await this.desktop.popSingleUseKey(); // eslint-disable-next-line no-await-in-loop await contact.addSingleUseKey(this.desktop, contactKey); } await this.phone.waitForSync(this.desktop); this.phone.resetSyncState(this.desktop); debug('synced with %j', this.desktop.debugId); return app; } public async linkAndClose(): Promise { const app = await this.link(); debug('closing the app after link'); await app.close(); } public async startApp(): Promise { assert( this.storagePath !== undefined, 'Bootstrap has to be initialized first, see: bootstrap.init()' ); debug('starting the app'); const { port } = this.server.address(); const app = new App({ main: ELECTRON, args: [CI_SCRIPT], config: await this.generateConfig(port), }); await app.start(); return app; } public getTimestamp(): number { const result = this.timestamp; this.timestamp += 1; return result; } // // Getters // public get phone(): PrimaryDevice { assert( this.privPhone, 'Bootstrap has to be initialized first, see: bootstrap.init()' ); return this.privPhone; } public get desktop(): Device { assert( this.privDesktop, 'Bootstrap has to be linked first, see: bootstrap.link()' ); return this.privDesktop; } public get contacts(): ReadonlyArray { assert( this.privContacts, 'Bootstrap has to be initialized first, see: bootstrap.init()' ); return this.privContacts; } // // Private // private async generateConfig(port: number): Promise { const url = `https://mock.signal.org:${port}`; return JSON.stringify({ ...(await loadCertificates()), forcePreloadBundle: this.options.benchmark, enableCI: true, buildExpiration: Date.now() + durations.MONTH, storagePath: this.storagePath, storageProfile: 'mock', serverUrl: url, storageUrl: url, directoryUrl: url, cdn: { '0': url, '2': url, }, updatesEnabled: false, ...this.options.extraConfig, }); } }