From 9f1e2f59716ca380f43f4631db95f83d9978e58b Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 27 May 2026 23:34:06 +0300 Subject: [PATCH] test(gherkin): cover event.test.before/after payload regression Adds three layers of coverage for the bug fixed in 770749e1 (gherkin beforeEach/afterEach emitting event.test.before with a placeholder test when the hook Context lacked .ctx.currentTest): * Unit (test/unit/bdd_test.js): six tests in a new "Gherkin hook events" block that drive the codeceptjs.before/after hooks with a mock Mocha Context and assert the real scenario title and tags reach event.test.before, event.test.after, the Before/After step-definition hooks, and that done is forwarded. * Runner (test/runner/bdd_test.js + test/data/sandbox): a capture plugin and codecept.bdd.events.js config that serialize event payloads to a temp JSON file so a spawned runner asserts the real @important/@very tags reach listeners end-to-end. * Acceptance (test/acceptance/gherkin/before_hook.feature + steps.js): a new Background+tagged feature whose Then step verifies both the BDD Before() hook and a direct event.test.before listener captured the real scenario. Also flips setup/teardown to prefer suite.ctx.currentTest over suite.currentTest, restoring the regular Scenario path while keeping the BDD fallback. Co-Authored-By: Claude Opus 4.7 --- lib/mocha/asyncWrapper.js | 4 +- test/acceptance/gherkin/before_hook.feature | 9 + test/acceptance/gherkin/steps.js | 61 +++++++ test/data/sandbox/codecept.bdd.events.js | 27 +++ .../sandbox/support/capture_test_before.js | 31 ++++ test/runner/bdd_test.js | 31 ++++ test/unit/bdd_test.js | 157 +++++++++++++++++- 7 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 test/acceptance/gherkin/before_hook.feature create mode 100644 test/data/sandbox/codecept.bdd.events.js create mode 100644 test/data/sandbox/support/capture_test_before.js diff --git a/lib/mocha/asyncWrapper.js b/lib/mocha/asyncWrapper.js index bb25aaabd..b15dd00cb 100644 --- a/lib/mocha/asyncWrapper.js +++ b/lib/mocha/asyncWrapper.js @@ -199,7 +199,7 @@ export function setup(suite) { recorder.startUnlessRunning() import('./test.js').then(testModule => { const { enhanceMochaTest } = testModule.default || testModule - event.emit(event.test.before, enhanceMochaTest(suite?.currentTest ?? suite?.ctx?.currentTest)) + event.emit(event.test.before, enhanceMochaTest(suite?.ctx?.currentTest ?? suite?.currentTest)) recorder.add(() => doneFn()) }) } @@ -211,7 +211,7 @@ export function teardown(suite) { recorder.startUnlessRunning() import('./test.js').then(testModule => { const { enhanceMochaTest } = testModule.default || testModule - event.emit(event.test.after, enhanceMochaTest(suite?.currentTest ?? suite?.ctx?.currentTest)) + event.emit(event.test.after, enhanceMochaTest(suite?.ctx?.currentTest ?? suite?.currentTest)) recorder.add(() => doneFn()) }) } diff --git a/test/acceptance/gherkin/before_hook.feature b/test/acceptance/gherkin/before_hook.feature new file mode 100644 index 000000000..163e326d0 --- /dev/null +++ b/test/acceptance/gherkin/before_hook.feature @@ -0,0 +1,9 @@ +@Playwright @Puppeteer @WebDriverIO @bdd @hookCapture +Feature: Before hooks receive the real scenario + + Background: + Given I opened website + + @scenarioHook + Scenario: Before hook captures scenario metadata + Then the Before hook should have captured this scenario diff --git a/test/acceptance/gherkin/steps.js b/test/acceptance/gherkin/steps.js index 2b59abeab..3e3836b3a 100644 --- a/test/acceptance/gherkin/steps.js +++ b/test/acceptance/gherkin/steps.js @@ -1,10 +1,71 @@ const I = actor(); +const event = codeceptjs.event; + +const capturedScenarios = []; + +Before(test => { + capturedScenarios.push({ + phase: 'Before-hook', + title: test && test.title, + tags: test && Array.isArray(test.tags) ? [...test.tags] : null, + }); +}); + +After(test => { + capturedScenarios.push({ + phase: 'After-hook', + title: test && test.title, + tags: test && Array.isArray(test.tags) ? [...test.tags] : null, + }); +}); + +event.dispatcher.on(event.test.before, test => { + capturedScenarios.push({ + phase: 'event.test.before', + title: test && test.title, + tags: test && Array.isArray(test.tags) ? [...test.tags] : null, + }); +}); + +event.dispatcher.on(event.test.after, test => { + capturedScenarios.push({ + phase: 'event.test.after', + title: test && test.title, + tags: test && Array.isArray(test.tags) ? [...test.tags] : null, + }); +}); Given('I opened website', () => { // From "gherkin/basic.feature" {"line":8,"column":5} I.amOnPage('/'); }); +Then('the Before hook should have captured this scenario', () => { + const captureSnapshot = JSON.stringify(capturedScenarios, null, 2); + + const matchesScenario = entry => + entry.tags && entry.tags.includes('@scenarioHook') && entry.tags.includes('@hookCapture'); + + const bddBefore = capturedScenarios.find( + entry => entry.phase === 'Before-hook' && matchesScenario(entry), + ); + const eventBefore = capturedScenarios.find( + entry => entry.phase === 'event.test.before' && matchesScenario(entry), + ); + + if (!bddBefore) throw new Error(`BDD Before() did not fire for @scenarioHook. Captured:\n${captureSnapshot}`); + if (!eventBefore) throw new Error(`event.test.before did not fire with real test. Captured:\n${captureSnapshot}`); + + for (const entry of [bddBefore, eventBefore]) { + if (!entry.title || entry.title === '...') { + throw new Error(`Placeholder title in ${entry.phase}: ${JSON.stringify(entry)}`); + } + if (!entry.title.includes('Before hook captures scenario metadata')) { + throw new Error(`Unexpected title in ${entry.phase}: ${JSON.stringify(entry)}`); + } + } +}); + When('go to {string} page', (url) => { // From "gherkin/basic.feature" {"line":9,"column":5} I.amOnPage(url); diff --git a/test/data/sandbox/codecept.bdd.events.js b/test/data/sandbox/codecept.bdd.events.js new file mode 100644 index 000000000..876d7f572 --- /dev/null +++ b/test/data/sandbox/codecept.bdd.events.js @@ -0,0 +1,27 @@ +export const config = { + tests: './*_no_test.js', + timeout: 10000, + output: './output', + helpers: { + BDD: { + require: './support/bdd_helper.js', + }, + }, + gherkin: { + features: './features/*.feature', + steps: [ + './features/step_definitions/my_steps.js', + './features/step_definitions/my_other_steps.js', + ], + }, + include: {}, + bootstrap: false, + mocha: {}, + plugins: { + captureTestBefore: { + require: './support/capture_test_before.js', + enabled: true, + }, + }, + name: 'sandbox', +} diff --git a/test/data/sandbox/support/capture_test_before.js b/test/data/sandbox/support/capture_test_before.js new file mode 100644 index 000000000..07dad0800 --- /dev/null +++ b/test/data/sandbox/support/capture_test_before.js @@ -0,0 +1,31 @@ +import fs from 'fs' +import event from '../../../../lib/event.js' + +export default function (config) { + const captured = [] + + event.dispatcher.on(event.test.before, test => { + captured.push({ + phase: 'test.before', + title: test?.title ?? null, + tags: Array.isArray(test?.tags) ? [...test.tags] : null, + }) + }) + + event.dispatcher.on(event.test.after, test => { + captured.push({ + phase: 'test.after', + title: test?.title ?? null, + tags: Array.isArray(test?.tags) ? [...test.tags] : null, + }) + }) + + const flush = () => { + const outPath = config.outputFile || process.env.CAPTURE_OUTPUT + if (!outPath) return + fs.writeFileSync(outPath, JSON.stringify(captured, null, 2)) + } + + event.dispatcher.on(event.all.result, flush) + event.dispatcher.on(event.all.after, flush) +} diff --git a/test/runner/bdd_test.js b/test/runner/bdd_test.js index 672c071fc..236133353 100644 --- a/test/runner/bdd_test.js +++ b/test/runner/bdd_test.js @@ -1,6 +1,8 @@ import * as chai from 'chai' chai.should() import assert from 'assert' +import fs from 'fs' +import os from 'os' import path from 'path' import { exec } from 'child_process' import { fileURLToPath } from 'url' @@ -347,6 +349,35 @@ When(/^I define a step with a \\( paren and a "(.*?)" string$/, () => { }) }) + it('emits event.test.before/after with the real scenario title and tags', done => { + const captureFile = path.join(os.tmpdir(), `codeceptjs-capture-${process.pid}-${Date.now()}.json`) + const cmd = `${config_run_config('codecept.bdd.events.js')} --grep "@important"` + exec(cmd, { env: { ...process.env, CAPTURE_OUTPUT: captureFile } }, (err, stdout, stderr) => { + try { + assert(!err, `runner failed: ${stderr || stdout}`) + const captured = JSON.parse(fs.readFileSync(captureFile, 'utf8')) + + const beforeEvents = captured.filter(e => e.phase === 'test.before') + const afterEvents = captured.filter(e => e.phase === 'test.after') + + beforeEvents.length.should.be.greaterThan(0) + afterEvents.length.should.be.greaterThan(0) + + for (const entry of [...beforeEvents, ...afterEvents]) { + entry.title.should.not.equal('...') + entry.title.should.include('checkout') + entry.tags.should.include('@important') + entry.tags.should.include('@very') + } + done() + } catch (assertErr) { + done(assertErr) + } finally { + try { fs.unlinkSync(captureFile) } catch {} + } + }) + }) + describe('i18n', () => { const codecept_dir = path.join(__dirname, '/../data/sandbox/i18n') const config_run_config = config => `${codecept_run} --config ${codecept_dir}/${config}` diff --git a/test/unit/bdd_test.js b/test/unit/bdd_test.js index eae122cf9..1fcc0c2a7 100644 --- a/test/unit/bdd_test.js +++ b/test/unit/bdd_test.js @@ -12,7 +12,7 @@ const builder = new Gherkin.AstBuilder(uuidFn) const matcher = new Gherkin.GherkinClassicTokenMatcher() import Config from '../../lib/config.js' -import { Given, When, And, Then, matchStep, clearSteps, defineParameterType } from '../../lib/mocha/bdd.js' +import { Given, When, And, Then, Before, After, matchStep, clearSteps, defineParameterType } from '../../lib/mocha/bdd.js' import run from '../../lib/mocha/gherkin.js' import recorder from '../../lib/recorder.js' import container from '../../lib/container.js' @@ -441,4 +441,159 @@ describe('BDD', () => { expect('blue').is.equal(color.name) await Promise.resolve() }) + + describe('Gherkin hook events', () => { + const featureText = ` + @feature_tag + Feature: checkout flow + + @scenario_tag + Scenario: buy a product + Given I have product with 600 price + When I go to checkout process + ` + + const registerBasicSteps = () => { + Given(/I have product with (\d+) price/, () => {}) + When('I go to checkout process', () => {}) + } + + const removeListeners = events => { + for (const name of events) event.dispatcher.removeAllListeners(name) + } + + const driveHook = (hook, currentTest) => + new Promise((resolve, reject) => { + hook.fn.call({ currentTest }, err => (err ? reject(err) : resolve())) + }) + + it('event.test.before carries the real scenario title and tags', async () => { + registerBasicSteps() + const suite = await run(featureText) + const captured = [] + event.dispatcher.on(event.test.before, t => captured.push({ title: t.title, tags: t.tags })) + + try { + const beforeHook = suite._beforeEach.find(h => h.title.includes('codeceptjs.before')) + await driveHook(beforeHook, suite.tests[0]) + + expect(captured).to.have.lengthOf(1) + expect(captured[0].title).to.equal('buy a product @scenario_tag') + expect(captured[0].tags).to.include.members(['@feature_tag', '@scenario_tag']) + expect(captured[0].title).to.not.equal('...') + } finally { + removeListeners([event.test.before]) + } + }) + + it('event.test.after carries the real scenario title and tags', async () => { + registerBasicSteps() + const suite = await run(featureText) + const captured = [] + event.dispatcher.on(event.test.after, t => captured.push({ title: t.title, tags: t.tags })) + + try { + const afterHook = suite._afterEach.find(h => h.title.includes('codeceptjs.after')) + await driveHook(afterHook, suite.tests[0]) + + expect(captured).to.have.lengthOf(1) + expect(captured[0].title).to.equal('buy a product @scenario_tag') + expect(captured[0].tags).to.include.members(['@feature_tag', '@scenario_tag']) + expect(captured[0].title).to.not.equal('...') + } finally { + removeListeners([event.test.after]) + } + }) + + it('forwards the done callback from setup() and emits before invoking it', async () => { + registerBasicSteps() + const suite = await run(featureText) + const beforeHook = suite._beforeEach.find(h => h.title.includes('codeceptjs.before')) + const order = [] + event.dispatcher.on(event.test.before, () => order.push('emitted')) + + try { + let doneCalls = 0 + await new Promise((resolve, reject) => { + beforeHook.fn.call({ currentTest: suite.tests[0] }, err => { + doneCalls++ + order.push('done') + err ? reject(err) : resolve() + }) + }) + expect(doneCalls).to.equal(1) + expect(order).to.deep.equal(['emitted', 'done']) + } finally { + removeListeners([event.test.before]) + } + }) + + it('Before(test => ...) step-definition hook receives the real scenario', async () => { + const seen = [] + Before(test => seen.push({ title: test.title, tags: test.tags })) + registerBasicSteps() + const suite = await run(featureText) + + try { + await new Promise((resolve, reject) => { + suite.tests[0].fn(err => (err ? reject(err) : resolve())) + }) + + expect(seen).to.have.lengthOf.at.least(1) + const recorded = seen[seen.length - 1] + expect(recorded.title).to.equal('buy a product @scenario_tag') + expect(recorded.tags).to.include.members(['@feature_tag', '@scenario_tag']) + } finally { + removeListeners([event.test.started]) + } + }) + + it('emits test.before, test.started, test.after in the expected order', async () => { + registerBasicSteps() + const suite = await run(featureText) + const order = [] + const record = name => () => order.push(name) + event.dispatcher.on(event.test.before, record('test.before')) + event.dispatcher.on(event.test.started, record('test.started')) + event.dispatcher.on(event.test.passed, record('test.passed')) + event.dispatcher.on(event.test.after, record('test.after')) + + try { + const beforeHook = suite._beforeEach.find(h => h.title.includes('codeceptjs.before')) + const afterHook = suite._afterEach.find(h => h.title.includes('codeceptjs.after')) + + await driveHook(beforeHook, suite.tests[0]) + await new Promise((resolve, reject) => { + suite.tests[0].fn(err => (err ? reject(err) : resolve())) + }) + await driveHook(afterHook, suite.tests[0]) + + expect(order.indexOf('test.before')).to.be.lessThan(order.indexOf('test.started')) + expect(order.indexOf('test.started')).to.be.lessThan(order.indexOf('test.passed')) + expect(order.indexOf('test.passed')).to.be.lessThan(order.indexOf('test.after')) + } finally { + removeListeners([event.test.before, event.test.started, event.test.passed, event.test.after]) + } + }) + + it('After(test => ...) step-definition hook receives the real scenario', async () => { + const seen = [] + After(test => seen.push({ title: test.title, tags: test.tags })) + registerBasicSteps() + const suite = await run(featureText) + + try { + await new Promise((resolve, reject) => { + suite.tests[0].fn(err => (err ? reject(err) : resolve())) + }) + + expect(seen).to.have.lengthOf.at.least(1) + const recorded = seen[seen.length - 1] + expect(recorded.title).to.equal('buy a product @scenario_tag') + expect(recorded.tags).to.include.members(['@feature_tag', '@scenario_tag']) + } finally { + removeListeners([event.test.finished]) + } + }) + }) })