// Copyright 2014-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only const { join } = require('path'); const importOnce = require('node-sass-import-once'); const rimraf = require('rimraf'); const mkdirp = require('mkdirp'); const spectron = require('spectron'); const asar = require('asar'); const fs = require('fs'); const os = require('os'); const assert = require('assert'); const sass = require('node-sass'); const packageJson = require('./package.json'); /* eslint-disable more/no-then, no-console */ module.exports = grunt => { async function promiseToAsyncGruntTask(promise, gruntDone) { let succeeded = false; try { await promise; succeeded = true; } catch (err) { grunt.log.error(err); } if (succeeded) { gruntDone(); } else { gruntDone(false); } } const bower = grunt.file.readJSON('bower.json'); const components = []; // eslint-disable-next-line guard-for-in, no-restricted-syntax for (const i in bower.concat.app) { components.push(bower.concat.app[i]); } grunt.loadNpmTasks('grunt-sass'); grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), concat: { components: { src: components, dest: 'js/components.js', }, test: { src: [ 'node_modules/mocha/mocha.js', 'node_modules/chai/chai.js', 'test/_test.js', ], dest: 'test/test.js', }, libtextsecuretest: { src: [ 'node_modules/jquery/dist/jquery.js', 'node_modules/mocha/mocha.js', 'node_modules/chai/chai.js', 'libtextsecure/test/_test.js', ], dest: 'libtextsecure/test/test.js', }, }, sass: { options: { implementation: sass, sourceMap: true, importer: importOnce, }, dev: { files: { 'stylesheets/manifest.css': 'stylesheets/manifest.scss', 'stylesheets/manifest_bridge.css': 'stylesheets/manifest_bridge.scss', }, }, }, copy: { deps: { files: [ { src: 'components/mp3lameencoder/lib/Mp3LameEncoder.js', dest: 'js/Mp3LameEncoder.min.js', }, { src: 'components/webaudiorecorder/lib/WebAudioRecorderMp3.js', dest: 'js/WebAudioRecorderMp3.js', }, ], }, }, watch: { protobuf: { files: ['./protos/SignalService.proto'], tasks: ['exec:build-protobuf'], }, sass: { files: ['./stylesheets/*.scss', './stylesheets/**/*.scss'], tasks: ['sass'], }, }, exec: { 'tx-pull-mostly-translated': { cmd: 'tx pull --all --use-git-timestamps --minimum-perc=80', }, 'tx-pull-any-existing-translation': { cmd: 'tx pull --use-git-timestamps', }, transpile: { cmd: 'yarn transpile', }, 'build-protobuf': { cmd: 'yarn build-protobuf', }, }, 'test-release': { osx: { archive: `mac/${packageJson.productName}.app/Contents/Resources/app.asar`, exe: `mac/${packageJson.productName}.app/Contents/MacOS/${packageJson.productName}`, }, mas: { archive: 'mas/Signal.app/Contents/Resources/app.asar', exe: `mas/${packageJson.productName}.app/Contents/MacOS/${packageJson.productName}`, }, linux: { archive: 'linux-unpacked/resources/app.asar', exe: `linux-unpacked/${packageJson.name}`, }, win: { archive: 'win-unpacked/resources/app.asar', exe: `win-unpacked/${packageJson.productName}.exe`, }, }, gitinfo: {}, // to be populated by grunt gitinfo }); Object.keys(grunt.config.get('pkg').devDependencies).forEach(key => { if (/^grunt(?!(-cli)?$)/.test(key)) { // ignore grunt and grunt-cli grunt.loadNpmTasks(key); } }); // Transifex does not understand placeholders, so this task patches all non-en // locales with missing placeholders grunt.registerTask('locale-patch', () => { const en = grunt.file.readJSON('_locales/en/messages.json'); grunt.file.recurse('_locales', (abspath, rootdir, subdir, filename) => { if (subdir === 'en' || filename !== 'messages.json') { return; } const messages = grunt.file.readJSON(abspath); // eslint-disable-next-line no-restricted-syntax for (const key in messages) { if (en[key] !== undefined && messages[key] !== undefined) { if ( en[key].placeholders !== undefined && messages[key].placeholders === undefined ) { messages[key].placeholders = en[key].placeholders; } } } grunt.file.write(abspath, `${JSON.stringify(messages, null, 4)}\n`); }); }); grunt.registerTask('getExpireTime', () => { grunt.task.requires('gitinfo'); const gitinfo = grunt.config.get('gitinfo'); const committed = gitinfo.local.branch.current.lastCommitTime; const buildCreation = Date.parse(committed); const buildExpiration = buildCreation + 1000 * 60 * 60 * 24 * 90; grunt.file.write( 'config/local-production.json', `${JSON.stringify({ buildCreation, buildExpiration })}\n` ); }); grunt.registerTask('clean-release', () => { rimraf.sync('release'); mkdirp.sync('release'); }); async function runTests(environment) { const { Application } = spectron; const electronBinary = process.platform === 'win32' ? 'electron.cmd' : 'electron'; const path = join(__dirname, 'node_modules', '.bin', electronBinary); const args = [join(__dirname, 'app', 'main.js')]; grunt.log.writeln('Starting path', path, 'with args', args); const app = new Application({ path, args, env: { NODE_ENV: environment, }, requireName: 'unused', startTimeout: 30000, }); function getMochaResults() { // eslint-disable-next-line no-undef return window.mochaResults; } async function logForFailure() { const temporaryDirectory = join( os.tmpdir(), `Signal-Desktop-tests--${Date.now()}-${Math.random() .toString() .slice(2)}` ); const renderProcessLogPath = join( temporaryDirectory, 'render-process.log' ); const mainProcessLogPath = join(temporaryDirectory, 'main-process.log'); await fs.promises.mkdir(temporaryDirectory, { recursive: true }); await Promise.all([ (async () => { const logs = await app.client.getRenderProcessLogs(); await fs.promises.writeFile( renderProcessLogPath, logs.map(log => JSON.stringify(log)).join('\n') ); })(), (async () => { const logs = await app.client.getMainProcessLogs(); await fs.promises.writeFile(mainProcessLogPath, logs.join('\n')); })(), ]); console.error(); grunt.log.error( `Renderer process logs written to ${renderProcessLogPath}` ); grunt.log.error(`Renderer process logs written to ${mainProcessLogPath}`); grunt.log.error( `For easier debugging, try NODE_ENV='${environment}' yarn start` ); console.error(); } try { await app.start(); grunt.log.writeln('App started. Now waiting for test results...'); await app.client.waitUntil( () => app.client.execute(getMochaResults).then(data => Boolean(data.value)), 25000, 'Expected to find window.mochaResults set!' ); const results = (await app.client.execute(getMochaResults)).value; if (!results) { await logForFailure(); throw new Error("Couldn't extract test results"); } if (results.failures > 0) { const errorMessage = `Found ${results.failures} failing test${ results.failures === 1 ? '' : 's' }.`; grunt.log.error(errorMessage); results.reports.forEach(report => { grunt.log.error(JSON.stringify(report, null, 2)); }); await logForFailure(); throw new Error(errorMessage); } grunt.log.ok(`${results.passes} tests passed.`); } finally { if (app.isRunning()) { await app.stop(); } } } grunt.registerTask( 'unit-tests', 'Run unit tests w/Electron', function thisNeeded() { const environment = grunt.option('env') || 'test'; promiseToAsyncGruntTask(runTests(environment), this.async()); } ); grunt.registerTask( 'lib-unit-tests', 'Run libtextsecure unit tests w/Electron', function thisNeeded() { const environment = grunt.option('env') || 'test-lib'; promiseToAsyncGruntTask(runTests(environment), this.async()); } ); grunt.registerMultiTask( 'test-release', 'Test packaged releases', function thisNeeded() { const dir = grunt.option('dir') || 'release'; const environment = grunt.option('env') || 'production'; const config = this.data; const archive = [dir, config.archive].join('/'); const files = [ 'config/default.json', `config/${environment}.json`, `config/local-${environment}.json`, ]; console.log(this.target, archive); const releaseFiles = files.concat(config.files || []); releaseFiles.forEach(fileName => { console.log(fileName); try { asar.statFile(archive, fileName); return true; } catch (e) { console.log(e); throw new Error(`Missing file ${fileName}`); } }); if (config.appUpdateYML) { const appUpdateYML = [dir, config.appUpdateYML].join('/'); if (fs.existsSync(appUpdateYML)) { console.log('auto update ok'); } else { throw new Error(`Missing auto update config ${appUpdateYML}`); } } const done = this.async(); // A simple test to verify a visible window is opened with a title const { Application } = spectron; const path = [dir, config.exe].join('/'); console.log('Starting path', path); const app = new Application({ path, }); const sleep = millis => new Promise(resolve => setTimeout(resolve, millis)); Promise.race([app.start(), sleep(15000)]) .then(() => { if (!app.isRunning()) { throw new Error('Application failed to start'); } return app.client.getWindowCount(); }) .then(count => { assert.equal(count, 1); console.log('window opened'); }) .then(() => // Verify the window's title app.client.waitUntil( async () => (await app.client.getTitle()) === packageJson.productName, { timeoutMsg: `Expected window title to be ${JSON.stringify( packageJson.productName )}`, } ) ) .then(() => { console.log('title ok'); }) .then(() => { assert( app.chromeDriver.logLines.indexOf(`NODE_ENV ${environment}`) > -1 ); console.log('environment ok'); }) .then( () => // Successfully completed test app.stop(), error => // Test failed! app.stop().then(() => { grunt.fail.fatal(`Test failed: ${error.message} ${error.stack}`); }) ) .catch(error => { console.log('Main process logs:'); app.client.getMainProcessLogs().then(logs => { logs.forEach(log => { console.log(log); }); // Test failed! grunt.fail.fatal(`Failure! ${error.message} ${error.stack}`); }); }) .then(done); } ); grunt.registerTask('tx', [ 'exec:tx-pull-mostly-translated', 'exec:tx-pull-any-existing-translation', 'locale-patch', ]); grunt.registerTask('dev', ['default', 'watch']); grunt.registerTask('test', ['unit-tests', 'lib-unit-tests']); grunt.registerTask('date', ['gitinfo', 'getExpireTime']); grunt.registerTask('default', [ 'exec:build-protobuf', 'exec:transpile', 'concat', 'copy:deps', 'sass', 'date', ]); };