From e62b0b0fd7b3857ecbcf9fb970f4d12900e13a3c Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Mon, 30 Oct 2023 17:54:59 +0200 Subject: [PATCH] feat(cli): detached runner --- detox/detox.d.ts | 7 ++++ detox/local-cli/test.test.js | 40 +++++++++++++++++++ .../testCommand/TestRunnerCommand.js | 29 ++++++++++++-- detox/local-cli/utils/interruptListeners.js | 15 +++++++ detox/runners/jest/testEnvironment/index.js | 8 +++- .../src/configuration/composeRunnerConfig.js | 4 +- .../configuration/composeRunnerConfig.test.js | 8 ++++ detox/src/ipc/IPCServer.js | 4 +- detox/src/ipc/ipc.test.js | 14 +++++++ detox/test/e2e/detox.config.js | 4 +- detox/test/integration/jest.config.js | 2 + docs/config/testRunner.mdx | 10 +++++ 12 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 detox/local-cli/utils/interruptListeners.js diff --git a/detox/detox.d.ts b/detox/detox.d.ts index 316493416f..785321e455 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -235,6 +235,13 @@ declare global { * @see {DetoxInternals.DetoxTestFileReport#isPermanentFailure} */ bail?: boolean; + /** + * When true, tells `detox test` to spawn the test runner in a detached mode. + * This is useful in CI environments, where you want to intercept SIGINT and SIGTERM signals to gracefully shut down the test runner and the device. + * Instead of passing the kill signal to the child process (the test runner), Detox will send an emergency shutdown request to all the workers, and then it will wait for them to finish. + * @default false + */ + detached?: boolean; /** * Custom handler to process --inspect-brk CLI flag. * Use it when you rely on another test runner than Jest to mutate the config. diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js index c29ce59959..4c86cfca1f 100644 --- a/detox/local-cli/test.test.js +++ b/detox/local-cli/test.test.js @@ -5,6 +5,7 @@ if (process.platform === 'win32') { jest.mock('../src/logger/DetoxLogger'); jest.mock('./utils/jestInternals'); +jest.mock('./utils/interruptListeners'); const cp = require('child_process'); const cpSpawn = cp.spawn; @@ -18,6 +19,8 @@ const { buildMockCommand, callCli } = require('../__tests__/helpers'); const { DEVICE_LAUNCH_ARGS_DEPRECATION } = require('./testCommand/warnings'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + describe('CLI', () => { let _env; let logger; @@ -143,6 +146,38 @@ describe('CLI', () => { }); }); + describe('detached runner', () => { + beforeEach(() => { + detoxConfig.testRunner.detached = true; + }); + + test('should be able to run as you would normally expect', async () => { + await run(); + expect(_.last(cliCall().argv)).toEqual('e2e/config.json'); + }); + + test('should intercept SIGINT and SIGTERM', async () => { + const { subscribe, unsubscribe } = jest.requireMock('./utils/interruptListeners'); + const simulateSIGINT = () => subscribe.mock.calls[0][0](); + + mockExitCode(1); + mockLongRun(2000); + + await Promise.all([ + run('--retries 2').catch(_.noop), + sleep(1000).then(() => { + simulateSIGINT(); + simulateSIGINT(); + expect(unsubscribe).not.toHaveBeenCalled(); + }), + ]); + + expect(unsubscribe).toHaveBeenCalled(); + expect(cliCall(0)).not.toBe(null); + expect(cliCall(1)).toBe(null); + }); + }); + test('should use testRunner.args._ as default specs', async () => { detoxConfig.testRunner.args._ = ['e2e/sanity']; await run(); @@ -620,4 +655,9 @@ describe('CLI', () => { mockExecutable.options.exitCode = code; detoxConfig.testRunner.args.$0 = mockExecutable.cmd; } + + function mockLongRun(ms) { + mockExecutable.options.sleep = ms; + detoxConfig.testRunner.args.$0 = mockExecutable.cmd; + } }); diff --git a/detox/local-cli/testCommand/TestRunnerCommand.js b/detox/local-cli/testCommand/TestRunnerCommand.js index fcf4534732..1bc773b9cb 100644 --- a/detox/local-cli/testCommand/TestRunnerCommand.js +++ b/detox/local-cli/testCommand/TestRunnerCommand.js @@ -12,6 +12,7 @@ const { escapeSpaces, useForwardSlashes } = require('../../src/utils/shellUtils' const sleep = require('../../src/utils/sleep'); const AppStartCommand = require('../startCommand/AppStartCommand'); const { markErrorAsLogged } = require('../utils/cliErrorHandling'); +const interruptListeners = require('../utils/interruptListeners'); const TestRunnerError = require('./TestRunnerError'); @@ -28,10 +29,12 @@ class TestRunnerCommand { const appsConfig = opts.config.apps; this._argv = runnerConfig.args; + this._detached = runnerConfig.detached; this._retries = runnerConfig.retries; this._envHint = this._buildEnvHint(opts.env); this._startCommands = this._prepareStartCommands(appsConfig, cliConfig); this._envFwd = {}; + this._terminating = false; if (runnerConfig.forwardEnv) { this._envFwd = this._buildEnvOverride(cliConfig, deviceConfig); @@ -59,16 +62,20 @@ class TestRunnerCommand { } catch (e) { launchError = e; + if (this._terminating) { + runsLeft = 0; + } + const failedTestFiles = detox.session.testResults.filter(r => !r.success); const { bail } = detox.config.testRunner; if (bail && failedTestFiles.some(r => r.isPermanentFailure)) { - throw e; + runsLeft = 0; } const testFilesToRetry = failedTestFiles.filter(r => !r.isPermanentFailure).map(r => r.testFilePath); - if (_.isEmpty(testFilesToRetry)) { - throw e; + if (testFilesToRetry.length === 0) { + runsLeft = 0; } if (--runsLeft > 0) { @@ -143,6 +150,15 @@ class TestRunnerCommand { }, _.isUndefined); } + _onTerminate = () => { + if (this._terminating) { + return; + } + + this._terminating = true; + return detox.unsafe_conductEarlyTeardown(); + }; + async _spawnTestRunner() { const fullCommand = this._buildSpawnArguments().map(escapeSpaces); const fullCommandWithHint = printEnvironmentVariables(this._envHint) + fullCommand.join(' '); @@ -153,6 +169,7 @@ class TestRunnerCommand { cp.spawn(fullCommand[0], fullCommand.slice(1), { shell: true, stdio: 'inherit', + detached: this._detached, env: _({}) .assign(process.env) .assign(this._envFwd) @@ -162,6 +179,8 @@ class TestRunnerCommand { }) .on('error', /* istanbul ignore next */ (err) => reject(err)) .on('exit', (code, signal) => { + interruptListeners.unsubscribe(this._onTerminate); + if (code === 0) { log.trace.end({ success: true }); resolve(); @@ -175,6 +194,10 @@ class TestRunnerCommand { reject(markErrorAsLogged(error)); } }); + + if (this._detached) { + interruptListeners.subscribe(this._onTerminate); + } }); } diff --git a/detox/local-cli/utils/interruptListeners.js b/detox/local-cli/utils/interruptListeners.js new file mode 100644 index 0000000000..d59ad52b46 --- /dev/null +++ b/detox/local-cli/utils/interruptListeners.js @@ -0,0 +1,15 @@ +function subscribe(listener) { + process.on('SIGINT', listener); + process.on('SIGTERM', listener); +} + +function unsubscribe(listener) { + process.removeListener('SIGINT', listener); + process.removeListener('SIGTERM', listener); +} + +module.exports = { + subscribe, + unsubscribe, +}; + diff --git a/detox/runners/jest/testEnvironment/index.js b/detox/runners/jest/testEnvironment/index.js index cddafe5e85..9418a65ed7 100644 --- a/detox/runners/jest/testEnvironment/index.js +++ b/detox/runners/jest/testEnvironment/index.js @@ -73,7 +73,9 @@ class DetoxCircusEnvironment extends NodeEnvironment { // @ts-expect-error TS2425 async handleTestEvent(event, state) { if (detox.session.unsafe_earlyTeardown) { - throw new Error('Detox halted test execution due to an early teardown request'); + if (event.name === 'test_fn_start' || event.name === 'hook_start') { + throw new Error('Detox halted test execution due to an early teardown request'); + } } this._timer.schedule(state.testTimeout != null ? state.testTimeout : this.setupTimeout); @@ -107,6 +109,10 @@ class DetoxCircusEnvironment extends NodeEnvironment { * @protected */ async initDetox() { + if (detox.session.unsafe_earlyTeardown) { + throw new Error('Detox halted test execution due to an early teardown request'); + } + const opts = { global: this.global, workerId: `w${process.env.JEST_WORKER_ID}`, diff --git a/detox/src/configuration/composeRunnerConfig.js b/detox/src/configuration/composeRunnerConfig.js index a367467521..2c3394e81f 100644 --- a/detox/src/configuration/composeRunnerConfig.js +++ b/detox/src/configuration/composeRunnerConfig.js @@ -32,6 +32,7 @@ function composeRunnerConfig(opts) { retries: 0, inspectBrk: inspectBrkHookDefault, forwardEnv: false, + detached: false, bail: false, jest: { setupTimeout: 300000, @@ -56,8 +57,9 @@ function composeRunnerConfig(opts) { if (typeof merged.inspectBrk === 'function') { if (cliConfig.inspectBrk) { - merged.retries = 0; + merged.detached = false; merged.forwardEnv = true; + merged.retries = 0; merged.inspectBrk(merged); } diff --git a/detox/src/configuration/composeRunnerConfig.test.js b/detox/src/configuration/composeRunnerConfig.test.js index 958fe04aa8..84bfc54503 100644 --- a/detox/src/configuration/composeRunnerConfig.test.js +++ b/detox/src/configuration/composeRunnerConfig.test.js @@ -46,6 +46,7 @@ describe('composeRunnerConfig', () => { }, retries: 0, bail: false, + detached: false, forwardEnv: false, }); }); @@ -60,6 +61,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }; @@ -77,6 +79,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }); }); @@ -92,6 +95,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }; @@ -109,6 +113,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }); }); @@ -222,6 +227,7 @@ describe('composeRunnerConfig', () => { reportSpecs: true, }, bail: true, + detached: true, retries: 1, }; @@ -236,6 +242,7 @@ describe('composeRunnerConfig', () => { reportSpecs: false, }, bail: false, + detached: false, retries: 3, }; @@ -256,6 +263,7 @@ describe('composeRunnerConfig', () => { reportWorkerAssign: true, }, bail: false, + detached: false, retries: 3, forwardEnv: false, }); diff --git a/detox/src/ipc/IPCServer.js b/detox/src/ipc/IPCServer.js index 1fba85ac27..1928c3e294 100644 --- a/detox/src/ipc/IPCServer.js +++ b/detox/src/ipc/IPCServer.js @@ -73,6 +73,7 @@ class IPCServer { this._ipc.server.emit(socket, 'registerContextDone', { testResults: this._sessionState.testResults, testSessionIndex: this._sessionState.testSessionIndex, + unsafe_earlyTeardown: this._sessionState.unsafe_earlyTeardown, }); } @@ -91,9 +92,8 @@ class IPCServer { } onConductEarlyTeardown(_data = null, socket = null) { - // Note that we don't save `unsafe_earlyTeardown` in the primary session state - // because it's transient and needed only to make the workers quit early. const newState = { unsafe_earlyTeardown: true }; + Object.assign(this._sessionState, newState); if (socket) { this._ipc.server.emit(socket, 'conductEarlyTeardownDone', newState); diff --git a/detox/src/ipc/ipc.test.js b/detox/src/ipc/ipc.test.js index 4414935ffe..d58a5b6d91 100644 --- a/detox/src/ipc/ipc.test.js +++ b/detox/src/ipc/ipc.test.js @@ -129,6 +129,20 @@ describe('IPC', () => { }); }); + describe('conductEarlyTeardown', () => { + beforeEach(() => ipcServer.init()); + beforeEach(() => ipcServer.onConductEarlyTeardown()); + + it('should change the session state', async () => { + expect(ipcServer.sessionState.unsafe_earlyTeardown).toEqual(true); + }); + + it('should pass the session state to the client', async () => { + await ipcClient1.init(); + expect(ipcClient1.sessionState.unsafe_earlyTeardown).toEqual(true); + }); + }); + describe('dispose()', () => { it('should resolve if there are no connected clients', async () => { await ipcServer.init(); diff --git a/detox/test/e2e/detox.config.js b/detox/test/e2e/detox.config.js index bd6a5a4c57..84a2a367b0 100644 --- a/detox/test/e2e/detox.config.js +++ b/detox/test/e2e/detox.config.js @@ -12,8 +12,10 @@ const config = { args: { $0: 'nyc jest', config: 'e2e/jest.config.js', - _: ['e2e/'] + forceExit: process.env.CI ? true : undefined, + _: ['e2e/'], }, + detached: !!process.env.CI, retries: process.env.CI ? 1 : undefined, jest: { setupTimeout: +`${process.env.DETOX_JEST_SETUP_TIMEOUT || 300000}`, diff --git a/detox/test/integration/jest.config.js b/detox/test/integration/jest.config.js index 680626e0f2..019df7af49 100644 --- a/detox/test/integration/jest.config.js +++ b/detox/test/integration/jest.config.js @@ -1,3 +1,5 @@ +process.env.CI = ''; // disable CI-specific behavior for integration tests + module.exports = { "maxWorkers": 1, "testMatch": ["/*.test.js"], diff --git a/docs/config/testRunner.mdx b/docs/config/testRunner.mdx index ca7638d5f4..524f5d2c1d 100644 --- a/docs/config/testRunner.mdx +++ b/docs/config/testRunner.mdx @@ -142,6 +142,16 @@ Default: `false`. When true, tells `detox test` to cancel next retrying if it gets at least one report about a [permanent test suite failure](../api/internals.mdx#reporting-test-results). Has no effect, if [`testRunner.retries`] is undefined or set to zero. +### `testRunner.detached` \[boolean] + +Default: `false`. + +When true, tells `detox test` to spawn the test runner in a detached mode. + +This is useful in CI environments, where you want to intercept SIGINT and SIGTERM signals to gracefully shut down the test runner and the device. + +Instead of passing the kill signal to the child process (the test runner), Detox will send an emergency shutdown request to all the workers, and then it will wait for them to finish. + ### `testRunner.forwardEnv` \[boolean] Default: `false`.