From a5742b464be66c61c737f5c49df44c51265ec100 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Tue, 12 Nov 2024 13:06:16 +1300 Subject: [PATCH] fix(main): fix tagged hooks implementation to match cucumber --- .changeset/metal-pillows-pretend.md | 13 ++++ .../main/gherkin-example/example.feature.js | 5 +- packages/main/src/hooks.ts | 64 +++++++++++-------- packages/main/src/index.ts | 4 +- packages/main/src/render.ts | 21 ++---- packages/main/src/tags.ts | 40 ++++++++++++ packages/main/src/world.ts | 15 ++++- packages/main/tests/hooks/hooks.feature | 1 + packages/main/tests/hooks/hooks.steps.ts | 18 ++++-- packages/main/tests/hooks/hooks2.feature | 5 ++ 10 files changed, 132 insertions(+), 54 deletions(-) create mode 100644 .changeset/metal-pillows-pretend.md create mode 100644 packages/main/tests/hooks/hooks2.feature diff --git a/.changeset/metal-pillows-pretend.md b/.changeset/metal-pillows-pretend.md new file mode 100644 index 0000000..1063389 --- /dev/null +++ b/.changeset/metal-pillows-pretend.md @@ -0,0 +1,13 @@ +--- +"quickpickle": patch +--- + +Fix tagged hooks to match [@cucumber/cucumber implementation]. +Tagged hooks are hooks that only run in the context of certain tags; +for example, a BeforeAll hook that only starts the server for +Features tagged with "@server". + +This change also adds info about the Feature to the "common" variable. + +[@cucumber/cucumber implementation]: +https://github.com/cucumber/cucumber-js/blob/main/docs/support_files/hooks.md diff --git a/packages/main/gherkin-example/example.feature.js b/packages/main/gherkin-example/example.feature.js index e7dc74f..ded0fee 100644 --- a/packages/main/gherkin-example/example.feature.js +++ b/packages/main/gherkin-example/example.feature.js @@ -8,7 +8,10 @@ import { let World = getWorldConstructor() -const common = {}; +const common = { info: { + feature: 'Feature: QuickPickle\'s Comprehensive Gherkin Syntax Example', + tags: ["@tag","@multiple_tags"] +}}; beforeAll(async () => { await applyHooks('beforeAll', common); diff --git a/packages/main/src/hooks.ts b/packages/main/src/hooks.ts index c77bf95..87a403b 100644 --- a/packages/main/src/hooks.ts +++ b/packages/main/src/hooks.ts @@ -1,10 +1,13 @@ import { isFunction, isString, isObject, concat } from 'lodash-es' -import { normalizeTags } from './tags'; +import { tagsFunction } from './tags'; + +type HookFunction = (state: any) => Promise interface Hook { - name: string; - f: (state: any) => Promise | any; - tags?: string|string[]; + name: string + f: HookFunction + tags: string + tagsFunction: (tags:string[]) => boolean } interface HookCollection { @@ -29,44 +32,49 @@ const hookNames: { [key: string]: string } = { afterStep: 'AfterStep', }; -export const applyHooks = async (hooksName: string, state: any): Promise => { +export const applyHooks = async (hooksName: string, state: any): Promise => { const hooks = allHooks[hooksName]; for (let i = 0; i < hooks.length; i++) { let hook = hooks[i] - if (!hook?.tags?.length || state?.tagsMatch(hook.tags)) { - await hook.f(state) + const result = hook.tagsFunction(state.info.tags.map((t:string) => t.toLowerCase())); + if (hooksName === 'beforeAll') console.log('hook.tags:N= ', hook.tags, ' result: ', result) + if (result) { + if (hooksName === 'beforeAll') console.log('FWAH') + await hook.f(state).then(() => { + if (hooksName === 'beforeAll') console.log('FWAH!!!') + }); } } return state; }; -const addHook = (hooksName: string, opts: string | Hook | ((state:any) => any), f?: (state:any) => any): void => { - let hookOpts: Hook; - - if (isFunction(opts)) { - hookOpts = { name: '', f: opts }; - } else if (isString(opts)) { - hookOpts = { name: opts, f: f! }; - } else if (isObject(opts)) { - hookOpts = opts as Hook - hookOpts.f = hookOpts.f || f! - hookOpts.tags = normalizeTags(hookOpts.tags) - } else { - throw new Error('Unknown options argument: ' + JSON.stringify(opts)); - } +const addHook = (hooksName: string, p1: string | Hook | HookFunction, p2?: string | string[] | HookFunction): void => { + + let hook:Hook = { name:'', f:async ()=>{}, tags:'', tagsFunction: () => true } + + if (isFunction(p1)) hook = { ...hook, f: p1} + else if (isString(p1)) hook.tags = p1 + else hook = { ...hook, ...p1 } + + if (isFunction(p2)) hook.f = p2 + + if (!hook.f) throw new Error('Function required: ' + JSON.stringify({ p1, p2 })) + + hook.tagsFunction = tagsFunction(hook.tags.toLowerCase()) - allHooks[hooksName] = concat(allHooks[hooksName], hookOpts); + allHooks[hooksName] = concat(allHooks[hooksName], hook); }; +type AddHookFunction = (p1: string | Hook | HookFunction, p2?: HookFunction) => void -export const BeforeAll = (opts: string | ((common:any) => void), f?: (common:any) => void): void => { addHook('beforeAll', opts, f) }; +export const BeforeAll:AddHookFunction = (p1,p2): void => { addHook('beforeAll', p1, p2) }; -export const Before = (opts: string | Hook | ((state:any) => void), f?: (state:any) => void): void => { addHook('before', opts, f) }; +export const Before:AddHookFunction = (p1,p2): void => { addHook('before', p1, p2) }; -export const BeforeStep = (opts: string | Hook | ((state:any) => void), f?: (state:any) => void): void => { addHook('beforeStep', opts, f) }; +export const BeforeStep:AddHookFunction = (p1,p2): void => { addHook('beforeStep', p1, p2) }; -export const AfterAll = (opts: string | ((common:any) => void), f?: (common:any) => void): void => { addHook('afterAll', opts, f) }; +export const AfterAll:AddHookFunction = (p1,p2): void => { addHook('afterAll', p1, p2) }; -export const After = (opts: string | Hook | ((state:any) => void), f?: (state:any) => void): void => { addHook('after', opts, f) }; +export const After:AddHookFunction = (p1,p2): void => { addHook('after', p1, p2) }; -export const AfterStep = (opts: string | Hook | ((state:any) => void), f?: (state:any) => void): void => { addHook('afterStep', opts, f) }; +export const AfterStep:AddHookFunction = (p1,p2): void => { addHook('afterStep', p1, p2) }; diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 74ef7bb..cd548ff 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -10,10 +10,10 @@ import { AfterStep, applyHooks, } from './hooks'; -import { explodeTags, tagsMatch, renderGherkin } from './render'; +import { explodeTags, renderGherkin } from './render'; import { DataTable } from '@cucumber/cucumber'; import { DocString } from './models/DocString'; -import { normalizeTags } from './tags'; +import { normalizeTags, tagsMatch } from './tags'; export { setWorldConstructor, getWorldConstructor, QuickPickleWorld, QuickPickleWorldInterface } from './world'; export { DocString, DataTable } diff --git a/packages/main/src/render.ts b/packages/main/src/render.ts index 123738b..bca84cc 100644 --- a/packages/main/src/render.ts +++ b/packages/main/src/render.ts @@ -1,9 +1,10 @@ -import type { Feature, FeatureChild, GherkinDocument, RuleChild, Step } from "@cucumber/messages"; +import type { Feature, FeatureChild, RuleChild, Step } from "@cucumber/messages"; import { type QuickPickleConfig } from '.' +import { tagsMatch, normalizeTags } from './tags' import * as Gherkin from '@cucumber/gherkin'; import * as Messages from '@cucumber/messages'; -import { fromPairs, intersection, pick, escapeRegExp } from "lodash-es"; +import { fromPairs, pick, escapeRegExp } from "lodash-es"; const uuidFn = Messages.IdGenerator.uuid(); const builder = new Gherkin.AstBuilder(uuidFn); @@ -32,7 +33,10 @@ import { let World = getWorldConstructor() -const common = {}; +const common = { info: { + feature: '${q(gherkinDocument.feature.keyword)}: ${q(gherkinDocument.feature.name)}', + tags: ${JSON.stringify(normalizeTags(gherkinDocument.feature.tags.map(t => t.name)))} +}}; beforeAll(async () => { await applyHooks('beforeAll', common); @@ -311,14 +315,3 @@ export function explodeTags(explodeTags:string[][], testTags:string[]):string[][ // finally, return the list return combined.length ? combined.map(arr => [...tagsToTest, ...arr]) : [testTags] } - -/** - * - * @param confTags string[] - * @param testTags string[] - * @returns boolean - */ -export function tagsMatch(confTags:string[], testTags:string[]) { - let tags = intersection(confTags.map(t => t.toLowerCase()), testTags.map(t => t.toLowerCase())) - return tags?.length ? tags : null -} \ No newline at end of file diff --git a/packages/main/src/tags.ts b/packages/main/src/tags.ts index a293598..a17885d 100644 --- a/packages/main/src/tags.ts +++ b/packages/main/src/tags.ts @@ -1,5 +1,45 @@ +import { intersection } from 'lodash-es' +import parse from '@cucumber/tag-expressions'; + export function normalizeTags(tags?:string|string[]|undefined):string[] { if (!tags) return [] tags = Array.isArray(tags) ? tags : tags.split(/\s*,\s*/g) return tags.filter(Boolean).map(tag => tag.startsWith('@') ? tag : `@${tag}`) } + +/** + * + * @param confTags string[] + * @param testTags string[] + * @returns boolean + */ +export function tagsMatch(confTags:string[], testTags:string[]) { + let tags = intersection(confTags.map(t => t.toLowerCase()), testTags.map(t => t.toLowerCase())) + return tags?.length ? tags : null +} + +interface TagExpression { + evaluate: (tags: string[]) => boolean; +} + +const parseTagsExpression = (tagsExpression: string): TagExpression => { + try { + const parsedExpression = parse(tagsExpression); + return parsedExpression; + } catch (error) { + throw new Error(`Failed to parse tag expression: ${(error as Error).message}`); + } +} + +export function tagsFunction(tagsExpression?:string):(tags: string[])=>boolean { + if (!tagsExpression) { + return () => true; + } + + const parsedTagsExpression = parseTagsExpression(tagsExpression); + + return (tags: string[]) => { + const result = parsedTagsExpression.evaluate(tags); + return result; + }; +} \ No newline at end of file diff --git a/packages/main/src/world.ts b/packages/main/src/world.ts index 6a8f9f2..7a8aed0 100644 --- a/packages/main/src/world.ts +++ b/packages/main/src/world.ts @@ -1,6 +1,15 @@ import type { TestContext } from 'vitest' -import { tagsMatch } from './render' +import { tagsMatch } from './tags' import { QuickPickleConfig } from '.' + +interface Common { + info: { + feature: string + tags: string[] + } + [key:string]: any +} + export interface QuickPickleWorldInterface { info: { config: QuickPickleConfig // the configuration for QuickPickle @@ -19,12 +28,12 @@ export interface QuickPickleWorldInterface { isComplete: boolean // (read only) whether the Scenario is on the last step config: QuickPickleConfig // (read only) configuration for QuickPickle worldConfig: QuickPickleConfig['worldConfig'] // (read only) configuration for the World - common: {[key: string]: any} // Common data shared across tests in one Feature file --- USE SPARINGLY + common: Common // Common data shared across tests in one Feature file --- USE SPARINGLY init: () => Promise // function called by QuickPickle when the world is created tagsMatch(tags: string[]): string[]|null // function to check if the Scenario tags match the given tags } -export type InfoConstructor = Omit & { common:{[key:string]:any} } +export type InfoConstructor = Omit & { common:Common } export class QuickPickleWorld implements QuickPickleWorldInterface { info: QuickPickleWorldInterface['info'] diff --git a/packages/main/tests/hooks/hooks.feature b/packages/main/tests/hooks/hooks.feature index 1723596..33f461a 100644 --- a/packages/main/tests/hooks/hooks.feature +++ b/packages/main/tests/hooks/hooks.feature @@ -6,6 +6,7 @@ Feature: Testing hooks Scenario: Hooks: The BeforeAll hook can set things in common Then the variable "common.beforeAll" should be "beforeAll" + And the typeof "common.taggedBeforeAll" should be "undefined" Scenario: Hooks: All hooks should work Given I run the tests diff --git a/packages/main/tests/hooks/hooks.steps.ts b/packages/main/tests/hooks/hooks.steps.ts index 152572f..ac68daf 100644 --- a/packages/main/tests/hooks/hooks.steps.ts +++ b/packages/main/tests/hooks/hooks.steps.ts @@ -4,11 +4,17 @@ import { Given, When, Then, BeforeAll, Before, BeforeStep, AfterStep, After, Aft const log:any = {} BeforeAll(async (common) => { + console.log('beforeAll') common.beforeAll = 'beforeAll' common.totalSteps = 0 log.tests = {} }) +BeforeAll('@taggedBeforeAll', async (state) => { + console.log('taggedBeforeAll') + state.taggedBeforeAll = 'taggedBeforeAll' +}) + Before(async (state) => { state.common.before = true log.tests[state.info.scenario] = [] @@ -21,7 +27,7 @@ BeforeStep(async (state) => { log.tests[state.info.scenario].push('BeforeStep') }) -AfterStep({ tags:'clearErrorsAfterStep' }, async (state) => { +AfterStep({ name:'Clear errors', tags: '@clearErrorsAfterStep' }, async (state) => { state.info.errors = [] }) @@ -60,12 +66,12 @@ const testWithError = [ 'After', ] -AfterAll(async (common) => { +AfterAll("not @taggedBeforeAll", async (common) => { console.log('AfterAll') - expect(common.beforeAll).toBe('beforeAll') - expect(common.before).toBe(true) - expect(common.totalSteps).not.toBeFalsy() - expect(log.tests).toMatchObject({ + await expect(common.beforeAll).toBe('beforeAll') + await expect(common.before).toBe(true) + await expect(common.totalSteps).not.toBeFalsy() + await expect(log.tests).toMatchObject({ 'Hooks: All hooks should work': testWithNoErrors, 'Hooks: Hooks also work on @soft tests': testWithNoErrors, 'Hooks: Errors are available in the hook': testWithError, diff --git a/packages/main/tests/hooks/hooks2.feature b/packages/main/tests/hooks/hooks2.feature new file mode 100644 index 0000000..6ca12a3 --- /dev/null +++ b/packages/main/tests/hooks/hooks2.feature @@ -0,0 +1,5 @@ +@taggedBeforeAll +Feature: Tags for BeforeAll work + + Scenario: Hooks: testing taggedBeforeAll + Then the variable "common.taggedBeforeAll" should be "taggedBeforeAll"