Skip to content

Commit

Permalink
fix(main): fix tagged hooks implementation to match cucumber
Browse files Browse the repository at this point in the history
  • Loading branch information
dnotes committed Nov 12, 2024
1 parent 59920d1 commit a5742b4
Show file tree
Hide file tree
Showing 10 changed files with 132 additions and 54 deletions.
13 changes: 13 additions & 0 deletions .changeset/metal-pillows-pretend.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion packages/main/gherkin-example/example.feature.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 36 additions & 28 deletions packages/main/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<void>

interface Hook {
name: string;
f: (state: any) => Promise<any> | any;
tags?: string|string[];
name: string
f: HookFunction
tags: string
tagsFunction: (tags:string[]) => boolean
}

interface HookCollection {
Expand All @@ -29,44 +32,49 @@ const hookNames: { [key: string]: string } = {
afterStep: 'AfterStep',
};

export const applyHooks = async (hooksName: string, state: any): Promise<any> => {
export const applyHooks = async (hooksName: string, state: any): Promise<void> => {
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) };
4 changes: 2 additions & 2 deletions packages/main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
21 changes: 7 additions & 14 deletions packages/main/src/render.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
}
40 changes: 40 additions & 0 deletions packages/main/src/tags.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
15 changes: 12 additions & 3 deletions packages/main/src/world.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<void> // 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<QuickPickleWorldInterface['info'], 'errors'> & { common:{[key:string]:any} }
export type InfoConstructor = Omit<QuickPickleWorldInterface['info'], 'errors'> & { common:Common }

export class QuickPickleWorld implements QuickPickleWorldInterface {
info: QuickPickleWorldInterface['info']
Expand Down
1 change: 1 addition & 0 deletions packages/main/tests/hooks/hooks.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions packages/main/tests/hooks/hooks.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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 = []
})

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/main/tests/hooks/hooks2.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@taggedBeforeAll
Feature: Tags for BeforeAll work

Scenario: Hooks: testing taggedBeforeAll
Then the variable "common.taggedBeforeAll" should be "taggedBeforeAll"

0 comments on commit a5742b4

Please sign in to comment.