diff --git a/lib/command/workers/runTests.js b/lib/command/workers/runTests.js index a9f0fd343..6303545ef 100644 --- a/lib/command/workers/runTests.js +++ b/lib/command/workers/runTests.js @@ -212,10 +212,36 @@ initPromise = (async function () { await runPoolTests() } else if (mocha.suite.total()) { await runTests() + await new Promise(resolve => setTimeout(resolve, 100)) + // Remove message listener before closing to prevent event loop hanging + if (global.parentMessageHandler) { + try { + parentPort?.off('message', global.parentMessageHandler) + } catch (err) { + // Ignore errors when removing listener + } + } + try { + parentPort?.close() + } catch (err) { + // Ignore errors when closing port + } } else { // No tests to run, close the worker console.error(`[Worker ${workerIndex}] ERROR: No tests found after filtering! Assigned ${tests.length} UIDs but none matched.`) - parentPort?.close() + // Remove message listener before closing to prevent event loop hanging + if (global.parentMessageHandler) { + try { + parentPort?.off('message', global.parentMessageHandler) + } catch (err) { + // Ignore errors when removing listener + } + } + try { + parentPort?.close() + } catch (err) { + // Ignore errors when closing port + } } } catch (err) { if (global.container?.tsFileMapping && fixErrorStack) { @@ -558,9 +584,11 @@ function sendToParentThread(data) { function listenToParentThread() { if (!poolMode) { - parentPort?.on('message', eventData => { + const messageHandler = eventData => { container.append({ support: eventData.data }) - }) + } + parentPort?.on('message', messageHandler) + global.parentMessageHandler = messageHandler } // In pool mode, message handling is done in runPoolTests() } diff --git a/lib/container.js b/lib/container.js index fc5bfb2e1..b73cbb5ae 100644 --- a/lib/container.js +++ b/lib/container.js @@ -674,6 +674,14 @@ async function createPlugins(config, options = {}) { // Use async loading for all plugins (ESM and CJS) plugins[pluginName] = await loadPluginAsync(module, config[pluginName]) + + // Skip loading plugin in parent process if runInParent is false + if (config[pluginName].runInParent === false && process.env.RUNS_WITH_WORKERS) { + delete plugins[pluginName] + debug(`plugin ${pluginName} skipped in parent process (runInParent: false)`) + continue + } + debug(`plugin ${pluginName} loaded via async import`) } catch (err) { throw new Error(`Could not load plugin ${pluginName} from module '${module}':\n${err.message}\n${err.stack}`) diff --git a/lib/listener/globalTimeout.js b/lib/listener/globalTimeout.js index 7acd9c78e..0b7ae6a2b 100644 --- a/lib/listener/globalTimeout.js +++ b/lib/listener/globalTimeout.js @@ -7,6 +7,7 @@ import debugModule from 'debug' const debug = debugModule('codeceptjs:timeout') import { TIMEOUT_ORDER, TimeoutError, TestTimeoutError, StepTimeoutError } from '../timeout.js' import { BeforeSuiteHook, AfterSuiteHook } from '../mocha/hooks.js' +import { extractStepCode } from '../step.js' export default function () { let timeout @@ -122,7 +123,7 @@ export default function () { if (typeof timeout !== 'number') return if (!store.timeouts) { - debug('step', step.toCode().trim(), 'timeout disabled') + debug('step', extractStepCode(step), 'timeout disabled') return } @@ -130,7 +131,7 @@ export default function () { debug('Previous steps timed out, setting timeout to 0.01s') step.setTimeout(0.01, TIMEOUT_ORDER.testOrSuite) } else { - debug(`Setting timeout ${timeout}ms for step ${step.toCode().trim()}`) + debug(`Setting timeout ${timeout}ms for step ${extractStepCode(step)}`) step.setTimeout(timeout, TIMEOUT_ORDER.testOrSuite) } }) @@ -158,17 +159,17 @@ export default function () { event.dispatcher.on(event.step.finished, step => { if (!store.timeouts) { - debug('step', step.toCode().trim(), 'timeout disabled') + debug('step', extractStepCode(step), 'timeout disabled') return } if (typeof timeout === 'number') debug('Timeout', timeout) - debug(`step ${step.toCode().trim()}:${step.status} duration`, step.duration) + debug(`step ${extractStepCode(step)}:${step.status} duration`, step.duration) if (typeof timeout === 'number' && !Number.isNaN(timeout)) timeout -= step.duration if (typeof timeout === 'number' && timeout <= 0 && recorder.isRunning()) { - debug(`step ${step.toCode().trim()} timed out`) + debug(`step ${extractStepCode(step)} timed out`) recorder.throw(new TestTimeoutError(currentTimeout)) } }) diff --git a/lib/plugin/autoDelay.js b/lib/plugin/autoDelay.js index 4f89b7013..89a1af148 100644 --- a/lib/plugin/autoDelay.js +++ b/lib/plugin/autoDelay.js @@ -15,6 +15,7 @@ const defaultConfig = { methods: methodsToDelay, delayBefore: 100, delayAfter: 200, + runInParent: false, } /** diff --git a/lib/plugin/pauseOnFail.js b/lib/plugin/pauseOnFail.js index 36511ff1b..e9987350b 100644 --- a/lib/plugin/pauseOnFail.js +++ b/lib/plugin/pauseOnFail.js @@ -22,7 +22,11 @@ import pause from '../pause.js' * ``` * */ -export default function() { +const defaultConfig = { + runInParent: false, +} + +export default function(config) { let failed = false event.dispatcher.on(event.test.started, () => { diff --git a/lib/plugin/retryFailedStep.js b/lib/plugin/retryFailedStep.js index e9bcac52e..0031dd03d 100644 --- a/lib/plugin/retryFailedStep.js +++ b/lib/plugin/retryFailedStep.js @@ -7,6 +7,7 @@ const defaultConfig = { defaultIgnoredSteps: ['amOnPage', 'wait*', 'send*', 'execute*', 'run*', 'have*'], factor: 1.5, ignoredSteps: [], + runInParent: false, deferToScenarioRetries: true, } diff --git a/lib/plugin/screenshotOnFail.js b/lib/plugin/screenshotOnFail.js index fa427e035..793add787 100644 --- a/lib/plugin/screenshotOnFail.js +++ b/lib/plugin/screenshotOnFail.js @@ -17,6 +17,7 @@ const defaultConfig = { uniqueScreenshotNames: false, disableScreenshots: false, fullPageScreenshots: false, + runInParent: false, } const supportedHelpers = Container.STANDARD_ACTING_HELPERS diff --git a/lib/plugin/stepTimeout.js b/lib/plugin/stepTimeout.js index 9250e26e7..831077b42 100644 --- a/lib/plugin/stepTimeout.js +++ b/lib/plugin/stepTimeout.js @@ -7,6 +7,7 @@ const defaultConfig = { overrideStepLimits: false, noTimeoutSteps: ['amOnPage', 'wait*'], customTimeoutSteps: [], + runInParent: false, } /** diff --git a/lib/step.js b/lib/step.js index 7500c6dff..ad9fdcc45 100644 --- a/lib/step.js +++ b/lib/step.js @@ -5,7 +5,7 @@ */ import BaseStep from './step/base.js' import StepConfig from './step/config.js' -import Step from './step/helper.js' +import Step, { extractStepCode } from './step/helper.js' /** * MetaStep is a step that is used to wrap other steps. @@ -20,4 +20,4 @@ import MetaStep from './step/meta.js' import FuncStep from './step/func.js' export default Step -export { MetaStep, BaseStep, StepConfig, FuncStep } +export { MetaStep, BaseStep, StepConfig, FuncStep, extractStepCode } diff --git a/lib/step/base.js b/lib/step/base.js index 1000c048e..b2ab35381 100644 --- a/lib/step/base.js +++ b/lib/step/base.js @@ -228,6 +228,7 @@ class Step { startTime: step.startTime, endTime: step.endTime, parent, + code: step.toCode(), } } diff --git a/lib/step/helper.js b/lib/step/helper.js index f13a892be..a97584f1d 100644 --- a/lib/step/helper.js +++ b/lib/step/helper.js @@ -40,6 +40,10 @@ class HelperStep extends Step { export default HelperStep +export function extractStepCode(step) { + return (step.code || (typeof step.toCode === 'function' ? step.toCode() : step.name)).trim() +} + function dryRunResolver() { return { get(target, prop) { diff --git a/lib/workers.js b/lib/workers.js index 0ed3a71b3..2a4471c75 100644 --- a/lib/workers.js +++ b/lib/workers.js @@ -626,18 +626,22 @@ class Workers extends EventEmitter { break case event.suite.before: this.emit(event.suite.before, deserializeSuite(message.data)) + event.dispatcher.emit(event.suite.before, deserializeSuite(message.data)) break case event.test.before: this.emit(event.test.before, deserializeTest(message.data)) + event.dispatcher.emit(event.test.before, deserializeTest(message.data)) break case event.test.started: this.emit(event.test.started, deserializeTest(message.data)) + event.dispatcher.emit(event.test.started, deserializeTest(message.data)) break case event.test.failed: // For hook failures, emit immediately as there won't be a test.finished event // Regular test failures are handled via test.finished to support retries if (message.data?.hookName) { this.emit(event.test.failed, deserializeTest(message.data)) + event.dispatcher.emit(event.test.failed, deserializeTest(message.data)) } // Otherwise skip - we'll emit based on finished state break @@ -646,6 +650,7 @@ class Workers extends EventEmitter { break case event.test.skipped: this.emit(event.test.skipped, deserializeTest(message.data)) + event.dispatcher.emit(event.test.skipped, deserializeTest(message.data)) break case event.test.finished: // Handle different types of test completion properly @@ -669,34 +674,44 @@ class Workers extends EventEmitter { // For tests without UID, emit immediately if (isFailed) { this.emit(event.test.failed, deserializeTest(data)) + event.dispatcher.emit(event.test.failed, deserializeTest(data)) } else { this.emit(event.test.passed, deserializeTest(data)) + event.dispatcher.emit(event.test.passed, deserializeTest(data)) } } this.emit(event.test.finished, deserializeTest(data)) + event.dispatcher.emit(event.test.finished, deserializeTest(data)) } break case event.test.after: this.emit(event.test.after, deserializeTest(message.data)) + event.dispatcher.emit(event.test.after, deserializeTest(message.data)) break case event.step.finished: this.emit(event.step.finished, message.data) + event.dispatcher.emit(event.step.finished, message.data) break case event.step.started: this.emit(event.step.started, message.data) + event.dispatcher.emit(event.step.started, message.data) break case event.step.passed: this.emit(event.step.passed, message.data) + event.dispatcher.emit(event.step.passed, message.data) break case event.step.failed: this.emit(event.step.failed, message.data, message.data.error) + event.dispatcher.emit(event.step.failed, message.data, message.data.error) break case event.hook.failed: // Hook failures are already reported as test failures by the worker // Just emit the hook.failed event for listeners this.emit(event.hook.failed, message.data) + event.dispatcher.emit(event.hook.failed, message.data) break + } }) diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 584689bd4..2f69a036c 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1580,7 +1580,8 @@ export function tests() { }, ) } catch (e) { - expect(e.message).to.include('expected all elements ({css: a[href="/codeceptjs/CodeceptJS"]}) to have attributes {"disable":true} "0" to equal "3"') + expect(e.message).to.include('expected all elements ({css: a[href="/codeceptjs/CodeceptJS"]}) to have attributes {"disable":true}') + expect(e.message).to.match(/"0" to equal "\d+"/) } })