From 655e6af31097102c5e8bdf7fa4adf317e35df33d Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Sun, 10 May 2026 10:57:45 +0300 Subject: [PATCH 1/2] fix(plugins): resolve async race conditions in aiTrace, analyze, screencast, pageInfo, heal Fix 8 bugs across 6 plugin files where async operations outside the recorder chain, missing force flags, and incorrect filtering caused silent data loss, premature process exit, and broken healing limits. Co-Authored-By: Claude Opus 4.6 --- lib/heal.js | 2 ++ lib/plugin/aiTrace.js | 29 ++++++++++++++++------------- lib/plugin/analyze.js | 16 +++++++++++++--- lib/plugin/heal.js | 5 +++-- lib/plugin/pageInfo.js | 12 +++++------- lib/plugin/screencast.js | 10 ++++++---- 6 files changed, 45 insertions(+), 29 deletions(-) diff --git a/lib/heal.js b/lib/heal.js index 53933495e..f1843e6b5 100644 --- a/lib/heal.js +++ b/lib/heal.js @@ -54,7 +54,9 @@ class Heal { async getCodeSuggestions(context) { const suggestions = [] + const stepName = context.step?.name const recipes = matchRecipes(this.recipes, this.contextName) + .filter(r => !r.steps || !stepName || r.steps.includes(stepName)) debug('Recipes', recipes) diff --git a/lib/plugin/aiTrace.js b/lib/plugin/aiTrace.js index bfd36f126..c759c3e7c 100644 --- a/lib/plugin/aiTrace.js +++ b/lib/plugin/aiTrace.js @@ -92,6 +92,7 @@ export default function (config = {}) { let testStartTime let currentUrl = null let testFailed = false + let pendingArtifactCapture = null let firstFailedStepSaved = false const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output @@ -129,6 +130,7 @@ export default function (config = {}) { currentUrl = null testFailed = false firstFailedStepSaved = false + pendingArtifactCapture = null }) event.dispatcher.on(event.step.after, step => { @@ -162,13 +164,12 @@ export default function (config = {}) { return } - const stepPersistPromise = persistStep(step).catch(err => { + recorder.add(`aiTrace step persistence: ${step.toString()}`, () => persistStep(step).catch(err => { output.debug(`aiTrace: Error saving step: ${err.message}`) - }) - recorder.add(`wait aiTrace step persistence: ${step.toString()}`, () => stepPersistPromise, true) + }), true) }) - event.dispatcher.on(event.step.failed, async step => { + event.dispatcher.on(event.step.failed, step => { if (!currentTest) return if (step.status === 'queued' && testFailed) { output.debug(`aiTrace: Skipping queued failed step "${step.toString()}" - testFailed: ${testFailed}`) @@ -188,11 +189,9 @@ export default function (config = {}) { } existingStep.status = 'failed' - try { - await captureArtifactsForStep(step, existingStep, existingStep.prefix) - } catch (err) { + pendingArtifactCapture = captureArtifactsForStep(step, existingStep, existingStep.prefix).catch(err => { output.debug(`aiTrace: Error updating failed step: ${err.message}`) - } + }) } else { if (stepNum === -1) return if (isStepIgnored(step)) return @@ -218,11 +217,9 @@ export default function (config = {}) { steps.push(stepData) firstFailedStepSaved = true - try { - await captureArtifactsForStep(step, stepData, stepPrefix) - } catch (err) { + pendingArtifactCapture = captureArtifactsForStep(step, stepData, stepPrefix).catch(err => { output.debug(`aiTrace: Error capturing failed step artifacts: ${err.message}`) - } + }) } }) @@ -238,7 +235,13 @@ export default function (config = {}) { if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') { return } - persist(test, 'failed') + recorder.add('aiTrace:persist failed', async () => { + if (pendingArtifactCapture) { + await pendingArtifactCapture + pendingArtifactCapture = null + } + persist(test, 'failed') + }, true) }) async function persistStep(step) { diff --git a/lib/plugin/analyze.js b/lib/plugin/analyze.js index b6feb7448..05d77fd8b 100644 --- a/lib/plugin/analyze.js +++ b/lib/plugin/analyze.js @@ -234,7 +234,12 @@ export default function (config = {}) { return } - printReport(result) + process.env.CODECEPT_DISABLE_AUTO_EXIT = '1' + try { + await printReport(result) + } finally { + process.exit(process.exitCode || 0) + } }) event.dispatcher.on(event.workers.result, async result => { @@ -248,7 +253,12 @@ export default function (config = {}) { return } - printReport(result) + process.env.CODECEPT_DISABLE_AUTO_EXIT = '1' + try { + await printReport(result) + } finally { + process.exit(process.exitCode || 0) + } }) async function printReport(result) { @@ -294,7 +304,7 @@ export default function (config = {}) { console.error('Error analyzing failed tests', err) } - if (!Object.keys(container.plugins()).includes('pageInfo')) { + if (!Object.keys(Container.plugins()).includes('pageInfo')) { console.log('To improve analysis, enable pageInfo plugin to get more context for failed tests.') } } diff --git a/lib/plugin/heal.js b/lib/plugin/heal.js index c8237dcf4..d487ca494 100644 --- a/lib/plugin/heal.js +++ b/lib/plugin/heal.js @@ -80,6 +80,7 @@ export default function (config = {}) { event.dispatcher.on(event.test.before, test => { currentTest = test healedSteps = 0 + healTries = 0 caughtError = null }) @@ -94,7 +95,9 @@ export default function (config = {}) { if (trigger.on === 'file' && !matchStepFile(step, trigger.path, trigger.line)) return recorder.catchWithoutStop(async err => { + if (healTries >= config.healLimit) throw err isHealing = true + healTries++ if (caughtError === err) throw err // avoid double handling caughtError = err @@ -121,8 +124,6 @@ export default function (config = {}) { await heal.healStep(step, err, { test }) - healTries++ - recorder.add('close healing session', () => { recorder.reset() recorder.session.restore('heal') diff --git a/lib/plugin/pageInfo.js b/lib/plugin/pageInfo.js index 3490f28f1..4965e5003 100644 --- a/lib/plugin/pageInfo.js +++ b/lib/plugin/pageInfo.js @@ -40,10 +40,10 @@ const defaultConfig = { export default function (config = {}) { config = Object.assign(defaultConfig, config) - const helper = pickActingHelper(Container.helpers()) - if (!helper) return - event.dispatcher.on(event.test.failed, test => { + const helper = pickActingHelper(Container.helpers()) + if (!helper) return + const pageState = {} recorder.add('pageInfo capture', async () => { @@ -60,8 +60,6 @@ export default function (config = {}) { if (captured.html) { const htmlPath = path.join(store.outputDir, captured.html) pageState.htmlSnapshot = htmlPath - // Scan raw HTML (pre-cleanHtml) so error classes containing digits - // or trash-class prefixes aren't stripped before detection. const htmlForScan = captured.htmlRaw || (() => { try { return fs.readFileSync(htmlPath, 'utf8') } catch { return '' } })() @@ -90,7 +88,7 @@ export default function (config = {}) { } catch {} } } catch {} - }) + }, true) recorder.add('Save page info', () => { test.addNote('pageInfo', pageStateToMarkdown(pageState)) @@ -99,7 +97,7 @@ export default function (config = {}) { fs.writeFileSync(pageStateFileName, pageStateToMarkdown(pageState)) test.artifacts.pageInfo = pageStateFileName return pageState - }) + }, true) }) } diff --git a/lib/plugin/screencast.js b/lib/plugin/screencast.js index bd0d9a3f0..76aa0a8ea 100644 --- a/lib/plugin/screencast.js +++ b/lib/plugin/screencast.js @@ -106,6 +106,12 @@ function wireScreencast(mode, options) { state.startedAt = options.subtitles ? Date.now() : null }) + event.dispatcher.on(event.test.started, test => { + if (!options.video || state.startQueued) return + state.startQueued = true + recorder.add('screencast:start', async () => startScreencast(state.test, options, state), true) + }) + event.dispatcher.on(event.step.started, step => { if (state.steps) { const at = Date.now() @@ -116,10 +122,6 @@ function wireScreencast(mode, options) { title: stepTitle(step), } } - if (!options.video || state.startQueued || !state.test) return - state.startQueued = true - const test = state.test - recorder.add('screencast:start', async () => startScreencast(test, options, state), true) }) if (options.subtitles) { From 559310bd641399d5062660ef3a79e2b1a06f7391 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 11 May 2026 02:30:43 +0300 Subject: [PATCH 2/2] fix(analyze,screencast): rework for new autoExit + fix unit tests analyze.js: CODECEPT_DISABLE_AUTO_EXIT was removed in #5556 (autoExit refactor). Push printReport onto the recorder so it's awaited inside codecept.run()'s done() before autoExit fires. screencast tests: emit event.test.started in unit tests to match the production event sequence (asyncWrapper.js fires it between event.test.before and the first event.step.started). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/plugin/analyze.js | 17 ++++------------- test/unit/plugin/screencast_test.js | 10 +++++++++- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/plugin/analyze.js b/lib/plugin/analyze.js index 05d77fd8b..08d2fb96c 100644 --- a/lib/plugin/analyze.js +++ b/lib/plugin/analyze.js @@ -12,6 +12,7 @@ const ai = aiModule.default || aiModule import colors from 'chalk' import ora from 'ora' import event from '../event.js' +import recorder from '../recorder.js' import output from '../output.js' @@ -227,19 +228,14 @@ export default function (config = {}) { console.log('Enabled AI analysis') }) - event.dispatcher.on(event.all.result, async result => { + event.dispatcher.on(event.all.result, result => { if (!isMainThread) return // run only on main thread if (!ai.isEnabled) { console.log('AI is disabled, no analysis will be performed. Run tests with --ai flag to enable it.') return } - process.env.CODECEPT_DISABLE_AUTO_EXIT = '1' - try { - await printReport(result) - } finally { - process.exit(process.exitCode || 0) - } + recorder.add('analyze:print-ai-report', () => printReport(result), true) }) event.dispatcher.on(event.workers.result, async result => { @@ -253,12 +249,7 @@ export default function (config = {}) { return } - process.env.CODECEPT_DISABLE_AUTO_EXIT = '1' - try { - await printReport(result) - } finally { - process.exit(process.exitCode || 0) - } + await printReport(result) }) async function printReport(result) { diff --git a/test/unit/plugin/screencast_test.js b/test/unit/plugin/screencast_test.js index f84ee09d2..84bd2e6d4 100644 --- a/test/unit/plugin/screencast_test.js +++ b/test/unit/plugin/screencast_test.js @@ -34,7 +34,7 @@ function fakeHelper(screencastApi) { function detachAll() { for (const evt of [ - event.test.before, event.test.after, event.test.failed, + event.test.before, event.test.started, event.test.after, event.test.failed, event.step.started, event.step.finished, ]) { event.dispatcher.removeAllListeners(evt) @@ -73,6 +73,7 @@ describe('screencast', () => { const test = createTest('keep-on-pass') event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) event.dispatcher.emit(event.step.started, aStep()) await recorder.promise() @@ -93,6 +94,7 @@ describe('screencast', () => { const test = createTest('delete-on-pass') event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) event.dispatcher.emit(event.step.started, aStep()) await recorder.promise() @@ -114,6 +116,7 @@ describe('screencast', () => { const test = createTest('keep-on-fail') event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) event.dispatcher.emit(event.step.started, aStep()) await recorder.promise() @@ -132,6 +135,7 @@ describe('screencast', () => { screencast({ on: 'test', captions: true }) let test = createTest('with-captions') event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) event.dispatcher.emit(event.step.started, aStep()) await recorder.promise() event.dispatcher.emit(event.test.after, test) @@ -144,6 +148,7 @@ describe('screencast', () => { screencast({ on: 'test', captions: false }) test = createTest('no-captions') event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) event.dispatcher.emit(event.step.started, aStep()) await recorder.promise() event.dispatcher.emit(event.test.after, test) @@ -159,6 +164,7 @@ describe('screencast', () => { const test = createTest('with-srt') event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) await recorder.promise() const step = { name: 'click', actor: 'I', args: ['Continue'] } @@ -183,6 +189,7 @@ describe('screencast', () => { test.artifacts.video = '/tmp/some-video-dir/myrun.webm' event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) const step = { name: 'see', actor: 'I', args: ['Github'] } event.dispatcher.emit(event.step.started, step) event.dispatcher.emit(event.step.finished, step) @@ -202,6 +209,7 @@ describe('screencast', () => { const test = createTest('no-api') event.dispatcher.emit(event.test.before, test) + event.dispatcher.emit(event.test.started, test) await recorder.promise() event.dispatcher.emit(event.test.after, test) await recorder.promise()