diff --git a/src/chromium/Launcher.ts b/src/chromium/Launcher.ts index c40f661ce0..879045a041 100644 --- a/src/chromium/Launcher.ts +++ b/src/chromium/Launcher.ts @@ -14,14 +14,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as childProcess from 'child_process'; + import * as fs from 'fs'; import * as http from 'http'; import * as https from 'https'; import * as os from 'os'; import * as path from 'path'; -import * as readline from 'readline'; -import * as removeFolder from 'rimraf'; import * as URL from 'url'; import { Browser } from './Browser'; import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher'; @@ -33,9 +31,9 @@ import { PipeTransport } from './PipeTransport'; import { WebSocketTransport } from './WebSocketTransport'; import { ConnectionTransport } from '../types'; import * as util from 'util'; +import { launchProcess, waitForLine } from '../processLauncher'; const mkdtempAsync = helper.promisify(fs.mkdtemp); -const removeFolderAsync = helper.promisify(removeFolder); const CHROME_PROFILE_PATH = path.join(os.tmpdir(), 'playwright_dev_profile-'); @@ -118,113 +116,45 @@ export class Launcher { } const usePipe = chromeArguments.includes('--remote-debugging-pipe'); - let stdio: ('ignore' | 'pipe')[] = ['pipe', 'pipe', 'pipe']; - if (usePipe) { - if (dumpio) - stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; - else - stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; - } - const chromeProcess = childProcess.spawn( - chromeExecutable, - chromeArguments, - { - // On non-windows platforms, `detached: true` makes child process a leader of a new - // process group, making it possible to kill child process tree with `.kill(-pid)` command. - // @see https://nodejs.org/api/child_process.html#child_process_options_detached - detached: process.platform !== 'win32', - env, - stdio - } - ); - if (!chromeProcess.pid) { - let reject: (e: Error) => void; - const result = new Promise((f, r) => reject = r); - chromeProcess.once('error', error => { - reject(new Error('Failed to launch browser: ' + error)); - }); - return result as Promise; - } - - if (dumpio) { - chromeProcess.stderr.pipe(process.stderr); - chromeProcess.stdout.pipe(process.stdout); - } - - let chromeClosed = false; - const waitForChromeToClose = new Promise((fulfill, reject) => { - chromeProcess.once('exit', () => { - chromeClosed = true; - // Cleanup as processes exit. - if (temporaryUserDataDir) { - removeFolderAsync(temporaryUserDataDir) - .then(() => fulfill()) - .catch((err: Error) => console.error(err)); - } else { - fulfill(); - } + const launched = await launchProcess({ + executablePath: chromeExecutable, + args: chromeArguments, + env, + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + pipe: usePipe, + tempDir: temporaryUserDataDir + }, () => { + if (temporaryUserDataDir || !connection) + return Promise.reject(); + return connection.rootSession.send('Browser.close').catch(error => { + debugError(error); + throw error; }); }); - const listeners = [ helper.addEventListener(process, 'exit', killChrome) ]; - if (handleSIGINT) - listeners.push(helper.addEventListener(process, 'SIGINT', () => { killChrome(); process.exit(130); })); - if (handleSIGTERM) - listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseChrome)); - if (handleSIGHUP) - listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseChrome)); let connection: Connection | null = null; try { if (!usePipe) { - const browserWSEndpoint = await waitForWSEndpoint(chromeProcess, timeout, this._preferredRevision); + const timeoutError = new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Chrome! The only Chrome revision guaranteed to work is r${this._preferredRevision}`); + const match = await waitForLine(launched.process, launched.process.stderr, /^DevTools listening on (ws:\/\/.*)$/, timeout, timeoutError); + const browserWSEndpoint = match[1]; const transport = await WebSocketTransport.create(browserWSEndpoint); connection = new Connection(browserWSEndpoint, transport, slowMo); } else { - const transport = new PipeTransport(chromeProcess.stdio[3] as NodeJS.WritableStream, chromeProcess.stdio[4] as NodeJS.ReadableStream); + const transport = new PipeTransport(launched.process.stdio[3] as NodeJS.WritableStream, launched.process.stdio[4] as NodeJS.ReadableStream); connection = new Connection('', transport, slowMo); } - const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, chromeProcess, gracefullyCloseChrome); + const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, launched.process, launched.gracefullyClose); await browser._waitForTarget(t => t.type() === 'page'); return browser; } catch (e) { - killChrome(); + await launched.gracefullyClose(); throw e; } - - function gracefullyCloseChrome(): Promise { - helper.removeEventListeners(listeners); - if (temporaryUserDataDir) { - killChrome(); - } else if (connection) { - // Attempt to close chrome gracefully - connection.rootSession.send('Browser.close').catch(error => { - debugError(error); - killChrome(); - }); - } - return waitForChromeToClose; - } - - // This method has to be sync to be used as 'exit' event handler. - function killChrome() { - helper.removeEventListeners(listeners); - if (chromeProcess.pid && !chromeProcess.killed && !chromeClosed) { - // Force kill chrome. - try { - if (process.platform === 'win32') - childProcess.execSync(`taskkill /pid ${chromeProcess.pid} /T /F`); - else - process.kill(-chromeProcess.pid, 'SIGKILL'); - } catch (e) { - // the process might have already stopped - } - } - // Attempt to remove temporary profile directory to avoid littering. - try { - removeFolder.sync(temporaryUserDataDir); - } catch (e) { } - } } defaultArgs(options: LauncherChromeArgOptions = {}): string[] { @@ -298,51 +228,6 @@ export class Launcher { } -function waitForWSEndpoint(chromeProcess: childProcess.ChildProcess, timeout: number, preferredRevision: string): Promise { - return new Promise((resolve, reject) => { - const rl = readline.createInterface({ input: chromeProcess.stderr }); - let stderr = ''; - const listeners = [ - helper.addEventListener(rl, 'line', onLine), - helper.addEventListener(rl, 'close', () => onClose()), - helper.addEventListener(chromeProcess, 'exit', () => onClose()), - helper.addEventListener(chromeProcess, 'error', error => onClose(error)) - ]; - const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; - - function onClose(error?: Error) { - cleanup(); - reject(new Error([ - 'Failed to launch chrome!' + (error ? ' ' + error.message : ''), - stderr, - '', - 'TROUBLESHOOTING: https://github.com/Microsoft/playwright/blob/master/docs/troubleshooting.md', - '', - ].join('\n'))); - } - - function onTimeout() { - cleanup(); - reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Chrome! The only Chrome revision guaranteed to work is r${preferredRevision}`)); - } - - function onLine(line: string) { - stderr += line + '\n'; - const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); - if (!match) - return; - cleanup(); - resolve(match[1]); - } - - function cleanup() { - if (timeoutId) - clearTimeout(timeoutId); - helper.removeEventListeners(listeners); - } - }); -} - function getWSEndpoint(browserURL: string): Promise { let resolve: (url: string) => void; let reject: (e: Error) => void; diff --git a/src/firefox/Browser.ts b/src/firefox/Browser.ts index 39bf89f80c..e7d904dff9 100644 --- a/src/firefox/Browser.ts +++ b/src/firefox/Browser.ts @@ -31,20 +31,20 @@ export class Browser extends EventEmitter implements BrowserInterface { private _connection: Connection; _defaultViewport: types.Viewport; private _process: import('child_process').ChildProcess; - private _closeCallback: () => void; + private _closeCallback: () => Promise; _targets: Map; private _defaultContext: BrowserContext; private _contexts: Map; private _eventListeners: RegisteredListener[]; - static async create(connection: Connection, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) { + static async create(connection: Connection, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => Promise) { const {browserContextIds} = await connection.send('Target.getBrowserContexts'); const browser = new Browser(connection, browserContextIds, defaultViewport, process, closeCallback); await connection.send('Target.enable'); return browser; } - constructor(connection: Connection, browserContextIds: Array, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) { + constructor(connection: Connection, browserContextIds: Array, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => Promise) { super(); this._connection = connection; this._defaultViewport = defaultViewport; @@ -167,7 +167,7 @@ export class Browser extends EventEmitter implements BrowserInterface { async close() { helper.removeEventListeners(this._eventListeners); - this._closeCallback(); + await this._closeCallback(); } _createBrowserContext(browserContextId: string | null): BrowserContext { diff --git a/src/firefox/Launcher.ts b/src/firefox/Launcher.ts index 883240410e..64be042f12 100644 --- a/src/firefox/Launcher.ts +++ b/src/firefox/Launcher.ts @@ -14,22 +14,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import * as os from 'os'; import * as path from 'path'; -import * as removeFolder from 'rimraf'; -import * as childProcess from 'child_process'; import {Connection} from './Connection'; import {Browser} from './Browser'; import {BrowserFetcher, BrowserFetcherOptions} from '../browserFetcher'; -import * as readline from 'readline'; import * as fs from 'fs'; import * as util from 'util'; -import {helper, debugError, assert} from '../helper'; +import {debugError, assert} from '../helper'; import {TimeoutError} from '../Errors'; import {WebSocketTransport} from './WebSocketTransport'; +import { launchProcess, waitForLine } from '../processLauncher'; const mkdtempAsync = util.promisify(fs.mkdtemp); -const removeFolderAsync = util.promisify(removeFolder); const FIREFOX_PROFILE_PATH = path.join(os.tmpdir(), 'playwright_firefox_profile-'); @@ -103,107 +101,45 @@ export class Launcher { throw new Error(missingText); firefoxExecutable = executablePath; } - const stdio = ['pipe', 'pipe', 'pipe']; - const firefoxProcess = childProcess.spawn( - firefoxExecutable, - firefoxArguments, - { - // On non-windows platforms, `detached: false` makes child process a leader of a new - // process group, making it possible to kill child process tree with `.kill(-pid)` command. - // @see https://nodejs.org/api/child_process.html#child_process_options_detached - detached: process.platform !== 'win32', - stdio, - // On linux Juggler ships the libstdc++ it was linked against. - env: os.platform() === 'linux' ? { - ...env, - LD_LIBRARY_PATH: `${path.dirname(firefoxExecutable)}:${process.env.LD_LIBRARY_PATH}`, - } : env, - } - ); - - if (!firefoxProcess.pid) { - let reject; - const result = new Promise((f, r) => reject = r); - firefoxProcess.once('error', error => { - reject(new Error('Failed to launch browser: ' + error)); - }); - return result as Promise; - } - - if (dumpio) { - firefoxProcess.stderr.pipe(process.stderr); - firefoxProcess.stdout.pipe(process.stdout); - } - - let firefoxClosed = false; - const waitForFirefoxToClose = new Promise((fulfill, reject) => { - firefoxProcess.once('close', () => { - firefoxClosed = true; - // Cleanup as processes exit. - if (temporaryProfileDir) { - removeFolderAsync(temporaryProfileDir) - .then(() => fulfill()) - .catch(err => console.error(err)); - } else { - fulfill(); - } + const launched = await launchProcess({ + executablePath: firefoxExecutable, + args: firefoxArguments, + env: os.platform() === 'linux' ? { + ...env, + // On linux Juggler ships the libstdc++ it was linked against. + LD_LIBRARY_PATH: `${path.dirname(firefoxExecutable)}:${process.env.LD_LIBRARY_PATH}`, + } : env, + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + pipe: false, + tempDir: temporaryProfileDir + }, () => { + if (temporaryProfileDir || !connection) + return Promise.reject(); + return connection.send('Browser.close').catch(error => { + debugError(error); + throw error; }); }); - const listeners = [ helper.addEventListener(process, 'close', killFirefox) ]; - if (handleSIGINT) - listeners.push(helper.addEventListener(process, 'SIGINT', () => { killFirefox(); process.exit(130); })); - if (handleSIGTERM) - listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseFirefox)); - if (handleSIGHUP) - listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseFirefox)); let connection: Connection | null = null; try { - const url = await waitForWSEndpoint(firefoxProcess, timeout); + const timeoutError = new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Firefox!`); + const match = await waitForLine(launched.process, launched.process.stdout, /^Juggler listening on (ws:\/\/.*)$/, timeout, timeoutError); + const url = match[1]; const transport = await WebSocketTransport.create(url); connection = new Connection(url, transport, slowMo); - const browser = await Browser.create(connection, defaultViewport, firefoxProcess, gracefullyCloseFirefox); + const browser = await Browser.create(connection, defaultViewport, launched.process, launched.gracefullyClose); if (ignoreHTTPSErrors) await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true}); await browser._waitForTarget(t => t.type() === 'page'); return browser; } catch (e) { - killFirefox(); + await launched.gracefullyClose; throw e; } - - function gracefullyCloseFirefox() { - helper.removeEventListeners(listeners); - if (temporaryProfileDir) { - killFirefox(); - } else if (connection) { - connection.send('Browser.close').catch(error => { - debugError(error); - killFirefox(); - }); - } - return waitForFirefoxToClose; - } - - // This method has to be sync to be used as 'exit' event handler. - function killFirefox() { - helper.removeEventListeners(listeners); - if (firefoxProcess.pid && !firefoxProcess.killed && !firefoxClosed) { - // Force kill chrome. - try { - if (process.platform === 'win32') - childProcess.execSync(`taskkill /pid ${firefoxProcess.pid} /T /F`); - else - process.kill(-firefoxProcess.pid, 'SIGKILL'); - } catch (e) { - // the process might have already stopped - } - } - // Attempt to remove temporary profile directory to avoid littering. - try { - removeFolder.sync(temporaryProfileDir); - } catch (e) { } - } } async connect(options: any = {}): Promise { @@ -234,48 +170,6 @@ export class Launcher { } } -function waitForWSEndpoint(firefoxProcess: import('child_process').ChildProcess, timeout: number): Promise { - return new Promise((resolve, reject) => { - const rl = readline.createInterface({ input: firefoxProcess.stdout }); - let stderr = ''; - const listeners = [ - helper.addEventListener(rl, 'line', onLine), - helper.addEventListener(rl, 'close', () => onClose()), - helper.addEventListener(firefoxProcess, 'error', error => onClose(error)) - ]; - const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; - - function onClose(error?: Error) { - cleanup(); - reject(new Error([ - 'Failed to launch Firefox!' + (error ? ' ' + error.message : ''), - stderr, - '', - ].join('\n'))); - } - - function onTimeout() { - cleanup(); - reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Firefox!`)); - } - - function onLine(line: string) { - stderr += line + '\n'; - const match = line.match(/^Juggler listening on (ws:\/\/.*)$/); - if (!match) - return; - cleanup(); - resolve(match[1]); - } - - function cleanup() { - if (timeoutId) - clearTimeout(timeoutId); - helper.removeEventListeners(listeners); - } - }); -} - export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher { const downloadURLs = { linux: '%s/builds/firefox/%s/firefox-linux.zip', diff --git a/src/webkit/Launcher.ts b/src/webkit/Launcher.ts index e6965db9e7..4cfc997865 100644 --- a/src/webkit/Launcher.ts +++ b/src/webkit/Launcher.ts @@ -14,8 +14,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as childProcess from 'child_process'; -import { debugError, helper, assert } from '../helper'; + +import { debugError, assert } from '../helper'; import { Browser } from './Browser'; import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher'; import { Connection } from './Connection'; @@ -25,6 +25,7 @@ import { execSync } from 'child_process'; import * as path from 'path'; import * as util from 'util'; import * as os from 'os'; +import { launchProcess } from '../processLauncher'; const DEFAULT_ARGS = [ ]; @@ -74,97 +75,41 @@ export class Launcher { throw new Error(missingText); webkitExecutable = executablePath; } - - let stdio: ('ignore' | 'pipe')[] = ['pipe', 'pipe', 'pipe']; - if (dumpio) - stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; - else - stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; webkitArguments.push('--inspector-pipe'); // Headless options is only implemented on Mac at the moment. if (process.platform === 'darwin' && options.headless !== false) webkitArguments.push('--headless'); - const webkitProcess = childProcess.spawn( - webkitExecutable, - webkitArguments, - { - // On non-windows platforms, `detached: true` makes child process a leader of a new - // process group, making it possible to kill child process tree with `.kill(-pid)` command. - // @see https://nodejs.org/api/child_process.html#child_process_options_detached - detached: process.platform !== 'win32', - env, - stdio - } - ); - if (!webkitProcess.pid) { - let reject; - const result = new Promise((f, r) => reject = r); - webkitProcess.once('error', error => { - reject(new Error('Failed to launch browser: ' + error)); - }); - return result as Promise; - } - - if (dumpio) { - webkitProcess.stderr.pipe(process.stderr); - webkitProcess.stdout.pipe(process.stdout); - } - - let webkitClosed = false; - const waitForChromeToClose = new Promise((fulfill, reject) => { - webkitProcess.once('exit', () => { - webkitClosed = true; - fulfill(); + const launched = await launchProcess({ + executablePath: webkitExecutable, + args: webkitArguments, + env, + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + pipe: true, + tempDir: null + }, () => { + if (!connection) + return Promise.reject(); + return connection.send('Browser.close').catch(error => { + debugError(error); + throw error; }); }); - const listeners = [ helper.addEventListener(process, 'exit', killWebKit) ]; - if (handleSIGINT) - listeners.push(helper.addEventListener(process, 'SIGINT', () => { killWebKit(); process.exit(130); })); - if (handleSIGTERM) - listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseWebkit)); - if (handleSIGHUP) - listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseWebkit)); let connection: Connection | null = null; try { - const transport = new PipeTransport(webkitProcess.stdio[3] as NodeJS.WritableStream, webkitProcess.stdio[4] as NodeJS.ReadableStream); + const transport = new PipeTransport(launched.process.stdio[3] as NodeJS.WritableStream, launched.process.stdio[4] as NodeJS.ReadableStream); connection = new Connection(transport, slowMo); - const browser = new Browser(connection, defaultViewport, webkitProcess, gracefullyCloseWebkit); + const browser = new Browser(connection, defaultViewport, launched.process, launched.gracefullyClose); await browser._waitForTarget(t => t._type === 'page'); return browser; } catch (e) { - killWebKit(); + await launched.gracefullyClose(); throw e; } - - function gracefullyCloseWebkit(): Promise { - helper.removeEventListeners(listeners); - if (connection) { - // Attempt to close chrome gracefully - connection.send('Browser.close').catch(error => { - debugError(error); - killWebKit(); - }); - } - return waitForChromeToClose; - } - - // This method has to be sync to be used as 'exit' event handler. - function killWebKit() { - helper.removeEventListeners(listeners); - if (webkitProcess.pid && !webkitProcess.killed && !webkitClosed) { - // Force kill chrome. - try { - if (process.platform === 'win32') - childProcess.execSync(`taskkill /pid ${webkitProcess.pid} /T /F`); - else - process.kill(-webkitProcess.pid, 'SIGKILL'); - } catch (e) { - // the process might have already stopped - } - } - } } executablePath(): string { diff --git a/test/playwright.spec.js b/test/playwright.spec.js index 5a2674f3b1..5377ca4791 100644 --- a/test/playwright.spec.js +++ b/test/playwright.spec.js @@ -189,7 +189,6 @@ module.exports.addTests = ({testRunner, product, playwrightPath}) => { require('./chromium/connect.spec.js').addTests(testOptions); require('./chromium/launcher.spec.js').addTests(testOptions); require('./chromium/headful.spec.js').addTests(testOptions); - require('./chromium/headful.spec.js').addTests(testOptions); require('./chromium/oopif.spec.js').addTests(testOptions); require('./chromium/tracing.spec.js').addTests(testOptions); } diff --git a/test/test.js b/test/test.js index b7df791dc9..1f3aa5a37a 100644 --- a/test/test.js +++ b/test/test.js @@ -73,48 +73,49 @@ beforeEach(async({server, httpsServer}) => { httpsServer.reset(); }); -if (process.env.BROWSER === 'firefox') { - describe('Firefox', () => { - require('./playwright.spec.js').addTests({ - product: 'Firefox', - playwrightPath: path.join(utils.projectRoot(), 'firefox.js'), - testRunner, - }); - }); -} else if (process.env.BROWSER === 'webkit') { - describe('WebKit', () => { - require('./playwright.spec.js').addTests({ - product: 'WebKit', - playwrightPath: path.join(utils.projectRoot(), 'webkit.js'), - testRunner, - }); - }); -} else { - describe('Chromium', () => { - require('./playwright.spec.js').addTests({ - product: 'Chromium', - playwrightPath: path.join(utils.projectRoot(), 'chromium.js'), - testRunner, - }); - if (process.env.COVERAGE) - utils.recordAPICoverage(testRunner, require('../lib/api').Chromium, require('../lib/chromium/events').Events); - }); -} - -if (process.env.CI && testRunner.hasFocusedTestsOrSuites()) { - console.error('ERROR: "focused" tests/suites are prohibitted on bots. Remove any "fit"/"fdescribe" declarations.'); - process.exit(1); -} - -new Reporter(testRunner, { - verbose: process.argv.includes('--verbose'), - summary: !process.argv.includes('--verbose'), - projectFolder: utils.projectRoot(), - showSlowTests: process.env.CI ? 5 : 0, -}); - (async() => { + if (process.env.BROWSER === 'firefox') { + await describe('Firefox', () => { + require('./playwright.spec.js').addTests({ + product: 'Firefox', + playwrightPath: path.join(utils.projectRoot(), 'firefox.js'), + testRunner, + }); + }); + } else if (process.env.BROWSER === 'webkit') { + await describe('WebKit', () => { + require('./playwright.spec.js').addTests({ + product: 'WebKit', + playwrightPath: path.join(utils.projectRoot(), 'webkit.js'), + testRunner, + }); + }); + } else { + await describe('Chromium', () => { + require('./playwright.spec.js').addTests({ + product: 'Chromium', + playwrightPath: path.join(utils.projectRoot(), 'chromium.js'), + testRunner, + }); + if (process.env.COVERAGE) + utils.recordAPICoverage(testRunner, require('../lib/api').Chromium, require('../lib/chromium/events').Events); + }); + } + + if (process.env.CI && testRunner.hasFocusedTestsOrSuites()) { + console.error('ERROR: "focused" tests/suites are prohibitted on bots. Remove any "fit"/"fdescribe" declarations.'); + process.exit(1); + } + + new Reporter(testRunner, { + verbose: process.argv.includes('--verbose'), + summary: !process.argv.includes('--verbose'), + projectFolder: utils.projectRoot(), + showSlowTests: process.env.CI ? 5 : 0, + }); + // await utils.initializeFlakinessDashboardIfNeeded(testRunner); - testRunner.run(); + const result = await testRunner.run(); + process.exit(result.terminationError ? 130 : 0); })();