Skip to content

Commit

Permalink
preserve arg types in call-step-from-step functions (#243)
Browse files Browse the repository at this point in the history
  • Loading branch information
vitalets committed Nov 29, 2024
1 parent 9370acb commit ffa6a6f
Show file tree
Hide file tree
Showing 15 changed files with 102 additions and 55 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* config option `featuresRoot` serves as a default directory for both `features` and `steps`, if these options are not explicitly defined
* feature: provide full localized step titles to Playwright HTML reporter ([#229](https://github.com/vitalets/playwright-bdd/issues/229), [#122](https://github.com/vitalets/playwright-bdd/issues/122))
* show background title in Playwright HTML reporter ([#122](https://github.com/vitalets/playwright-bdd/issues/122))
* preserve arg types in call-step-from-step functions ([#243](https://github.com/vitalets/playwright-bdd/issues/243))
* increase minimal Playwright version from 1.35 to 1.39

## 7.5.0
Expand Down
5 changes: 5 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,9 @@ npm i --no-save @cucumber/cucumber@10
Run tests:
```
npm t
```
## Clear Playwright compilation cache
```
npx playwright clear-cache
```
4 changes: 3 additions & 1 deletion src/reporter/cucumber/messagesBuilder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Pickles } from './Pickles';
import { ConcreteEnvelope } from './types';
import { getConfigFromEnv } from '../../../config/env';
import { TestFiles } from './TestFiles';
import { relativeToCwd } from '../../../utils/paths';

export class MessagesBuilder {
private report = {
Expand Down Expand Up @@ -64,7 +65,8 @@ export class MessagesBuilder {
// todo: move these line somewhere else
const bddTestData = bddData.find((data) => data.pwTestLine === test.location.line);
if (!bddTestData) {
throw new Error(`Cannot find bddTestData for ${test.location.file}:${test.location.line}`);
const filePath = relativeToCwd(test.location.file);
throw new Error(`Cannot find bddTestData for ${filePath}:${test.location.line}`);
}

// For skipped tests Playwright doesn't run fixtures
Expand Down
31 changes: 20 additions & 11 deletions src/steps/createBdd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ import { isTestContainsSubtest } from '../playwright/testTypeImpl';
import { exit } from '../utils/exit';
import { scenarioHookFactory } from '../hooks/scenario';
import { workerHookFactory } from '../hooks/worker';
import { CucumberStyleStepFn, cucumberStepCtor } from './styles/cucumberStyle';
import { PlaywrightStyleStepFn, playwrightStepCtor } from './styles/playwrightStyle';
import {
CucumberStyleStepCtor,
CucumberStyleStepFn,
cucumberStepCtor,
} from './styles/cucumberStyle';
import {
PlaywrightStyleStepCtor,
PlaywrightStyleStepFn,
playwrightStepCtor,
} from './styles/playwrightStyle';
import { BddWorkerFixtures } from '../runtime/bddWorkerFixtures';

type CreateBddOptions<WorldFixtureName> = {
Expand All @@ -40,7 +48,8 @@ export function createBdd<
>(customTest?: TestType<T, W>, options?: CreateBddOptions<WorldFixtureName>) {
// TypeScript does not narrow generic types by control flow
// see: https://github.com/microsoft/TypeScript/issues/33912
// So, we define return types separately using conditional types
// So, we define return types separately using conditional types.
// 'WorldFixtureName extends CustomFixtureNames<T>' means it's cucumber style.
type World =
WorldFixtureName extends CustomFixtureNames<T>
? T[WorldFixtureName] // prettier-ignore
Expand All @@ -53,8 +62,8 @@ export function createBdd<

type StepCtor =
WorldFixtureName extends CustomFixtureNames<T>
? ReturnType<typeof cucumberStepCtor<StepFn>>
: ReturnType<typeof playwrightStepCtor<StepFn>>;
? CucumberStyleStepCtor<StepFn>
: PlaywrightStyleStepCtor<StepFn>;

if (customTest === (baseBddTest as TestTypeCommon)) customTest = undefined;
if (customTest) assertTestHasBddFixtures(customTest);
Expand Down Expand Up @@ -89,10 +98,10 @@ export function createBdd<
When,
Then,
Step,
Before,
After,
BeforeAll,
AfterAll,
Before,
After,
BeforeWorker,
AfterWorker,
BeforeScenario,
Expand All @@ -107,14 +116,14 @@ export function createBdd<
const Step = playwrightStepCtor('Unknown', ctorOptions) as StepCtor;

return {
BeforeAll,
AfterAll,
Before,
After,
Given,
When,
Then,
Step,
BeforeAll,
AfterAll,
Before,
After,
BeforeWorker,
AfterWorker,
BeforeScenario,
Expand Down
5 changes: 3 additions & 2 deletions src/steps/stepDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ import { buildTagsExpression, TagsExpression } from './tags';

export type GherkinStepKeyword = 'Unknown' | 'Given' | 'When' | 'Then';
export type StepPattern = string | RegExp;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyFunction = (...args: any[]) => unknown;
export type ProvidedStepOptions = {
tags?: string;
};

export type StepDefinitionOptions = {
keyword: GherkinStepKeyword;
pattern: StepPattern;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fn: (...args: any[]) => unknown;
fn: AnyFunction;
location: PlaywrightLocation;
customTest?: TestTypeCommon;
pomNode?: PomNode; // for decorator steps
Expand Down
15 changes: 10 additions & 5 deletions src/steps/styles/cucumberStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@
import { getLocationByOffset } from '../../playwright/getLocationInFile';
import { registerStepDefinition } from '../stepRegistry';
import { BddAutoInjectFixtures } from '../../runtime/bddTestFixturesAuto';
import { GherkinStepKeyword, StepDefinitionOptions } from '../stepDefinition';
import { AnyFunction, GherkinStepKeyword } from '../stepDefinition';
import { parseStepDefinitionArgs, StepConstructorOptions, StepDefinitionArgs } from './shared';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CucumberStyleStepFn<World> = (this: World, ...args: any[]) => unknown;

export function cucumberStepCtor<StepFn extends StepDefinitionOptions['fn']>(
export type CucumberStyleStepCtor<T extends AnyFunction> = <StepFn extends T>(
...args: StepDefinitionArgs<StepFn>
) => StepFn;

export function cucumberStepCtor(
keyword: GherkinStepKeyword,
{ customTest, worldFixture, defaultTags }: StepConstructorOptions,
) {
return (...args: StepDefinitionArgs<StepFn>) => {
return <StepFn extends AnyFunction>(...args: StepDefinitionArgs<StepFn>) => {
const { pattern, providedOptions, fn } = parseStepDefinitionArgs(args);

registerStepDefinition({
Expand All @@ -33,9 +37,10 @@ export function cucumberStepCtor<StepFn extends StepDefinitionOptions['fn']>(
},
});

// returns function to be able to call this step from other steps
// returns function to be able to reuse this fn in other steps
// see: https://github.com/vitalets/playwright-bdd/issues/110
// Note: for new cucumber style we should call this fn with current world (add to docs)
// Note: for cucumber style we should call this fn with current world
// e.g.: fn.call(this, ...args)
return fn;
};
}
22 changes: 14 additions & 8 deletions src/steps/styles/playwrightStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,28 @@ import { fixtureParameterNames } from '../../playwright/fixtureParameterNames';
import { getLocationByOffset } from '../../playwright/getLocationInFile';
import { ParametersExceptFirst } from '../../utils/types';
import { registerStepDefinition } from '../stepRegistry';
import { StepPattern, GherkinStepKeyword, StepDefinitionOptions } from '../stepDefinition';
import { StepPattern, GherkinStepKeyword, AnyFunction } from '../stepDefinition';
import { parseStepDefinitionArgs, StepConstructorOptions, StepDefinitionArgs } from './shared';

export type PlaywrightStyleStepFn<T extends KeyValue, W extends KeyValue> = (
// for ps-style we still pass world as {}
// See: https://github.com/vitalets/playwright-bdd/issues/208
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this: Record<string, any>,
fixtures: T & W,
...args: any[] // eslint-disable-line @typescript-eslint/no-explicit-any
) => unknown;

export function playwrightStepCtor<StepFn extends StepDefinitionOptions['fn']>(
export type PlaywrightStyleStepCtor<T extends AnyFunction> = <StepFn extends T>(
...args: StepDefinitionArgs<StepFn>
) => ReturnType<typeof getCallableStepFn<StepFn>>;

// typings here are vague, we set exact typings inside createBdd()
export function playwrightStepCtor(
keyword: GherkinStepKeyword,
{ customTest, defaultTags }: StepConstructorOptions,
) {
return (...args: StepDefinitionArgs<StepFn>) => {
return <StepFn extends AnyFunction>(...args: StepDefinitionArgs<StepFn>) => {
const { pattern, providedOptions, fn } = parseStepDefinitionArgs(args);

registerStepDefinition({
Expand All @@ -39,18 +48,15 @@ export function playwrightStepCtor<StepFn extends StepDefinitionOptions['fn']>(
* Returns wrapped step function to be called from other steps.
* See: https://github.com/vitalets/playwright-bdd/issues/110
*/
function getCallableStepFn<StepFn extends StepDefinitionOptions['fn']>(
pattern: StepPattern,
fn: StepFn,
) {
function getCallableStepFn<StepFn extends AnyFunction>(pattern: StepPattern, fn: StepFn) {
// need Partial<...> here, otherwise TS requires all Playwright fixtures to be passed
return (fixtures: Partial<Parameters<StepFn>[0]>, ...args: ParametersExceptFirst<StepFn>) => {
assertStepIsCalledWithRequiredFixtures(pattern, fn, fixtures);
return fn(fixtures, ...args);
};
}

function assertStepIsCalledWithRequiredFixtures<StepFn extends StepDefinitionOptions['fn']>(
function assertStepIsCalledWithRequiredFixtures<StepFn extends AnyFunction>(
pattern: StepPattern,
fn: StepFn,
passedFixtures: Partial<Parameters<StepFn>[0]>,
Expand Down
6 changes: 3 additions & 3 deletions src/steps/styles/shared.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { TestTypeCommon } from '../../playwright/types';
import { ProvidedStepOptions, StepDefinitionOptions, StepPattern } from '../stepDefinition';
import { AnyFunction, ProvidedStepOptions, StepPattern } from '../stepDefinition';

export type StepDefinitionArgs<StepFn extends StepDefinitionOptions['fn']> =
export type StepDefinitionArgs<StepFn extends AnyFunction> =
| [pattern: StepPattern, fn: StepFn]
| [pattern: StepPattern, providedOptions: ProvidedStepOptions, fn: StepFn];

export function parseStepDefinitionArgs<StepFn extends StepDefinitionOptions['fn']>(
export function parseStepDefinitionArgs<StepFn extends AnyFunction>(
args: StepDefinitionArgs<StepFn>,
) {
const [pattern, providedOptions, fn] = args.length === 3 ? args : [args[0], {}, args[1]];
Expand Down
5 changes: 1 addition & 4 deletions test/bdd-syntax/steps-cucumber-style/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { expectTypeOf } from 'expect-type';
import { World } from './world';

Given('State {int}', async function () {
expectTypeOf(this).toEqualTypeOf<World>();
// noop
});

Expand Down Expand Up @@ -65,7 +66,3 @@ Then('Context prop {string} to equal {string}', async function (key: string, val
Then('Tags are {string}', async function (tags: string) {
expect(this.tags.join(' ')).toEqual(tags);
});

Then('This step is not used, defined for checking types', async function () {
expectTypeOf(this).toEqualTypeOf<World>();
});
7 changes: 4 additions & 3 deletions test/bdd-syntax/steps-pw-style/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ Given('State {int}', async () => {

Given('Set context prop {string} = {string}', async function ({ ctx }, key: string, value: string) {
ctx[key] = value;
// @ts-expect-error 'this' is any in pw-style steps
this[key] = value;
});

Expand Down Expand Up @@ -56,7 +55,6 @@ Then(
'Context prop {string} to equal {string}',
async function ({ ctx }, key: string, value: string) {
expect(String(ctx[key])).toEqual(value);
// @ts-expect-error 'this' is any in pw-style steps
expect(String(this[key])).toEqual(value);
},
);
Expand All @@ -66,11 +64,14 @@ Step('Tags are {string}', async ({ $tags, tagsFromCustomFixture }, tags: string)
expect(tagsFromCustomFixture.join(' ')).toEqual(tags);
});

// don't use this step b/c it creates page
Then(
'This step is not used, defined for checking types',
async ({ page, $tags, tagsFromCustomFixture }) => {
async function ({ page, $tags, tagsFromCustomFixture }) {
expectTypeOf(page).toEqualTypeOf<Page>();
expectTypeOf($tags).toEqualTypeOf<string[]>();
expectTypeOf(tagsFromCustomFixture).toEqualTypeOf<string[]>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectTypeOf(this).toEqualTypeOf<Record<string, any>>();
},
);
30 changes: 24 additions & 6 deletions test/reporter-cucumber-msg/features/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
import { Given as GivenC, When as WhenC, Then as ThenC } from '@cucumber/cucumber';
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as cucumber from '@cucumber/cucumber';
import { test as base, createBdd } from 'playwright-bdd';

const isPlaywrightRun = Boolean(process.env.PLAYWRIGHT_BDD_CONFIGS);

export const test = base.extend({
world: ({}, use, testInfo) => use({ testInfo }),
});
const pwBdd = createBdd(test, { worldFixture: 'world' });

const isPlaywrightRun = Boolean(process.env.PLAYWRIGHT_BDD_CONFIGS);
type StepFn = (this: Record<string, any>, ...args: any[]) => unknown;

export const Given = function (pattern: string, fn: StepFn) {
return isPlaywrightRun ? pwBdd.Given(pattern, fn) : cucumber.Given(pattern, fn);
};

export const When = function (pattern: string, fn: StepFn) {
return isPlaywrightRun ? pwBdd.When(pattern, fn) : cucumber.When(pattern, fn);
};

export const Then = function (pattern: string, fn: StepFn) {
return isPlaywrightRun ? pwBdd.Then(pattern, fn) : cucumber.Then(pattern, fn);
};

export const { Given, When, Then } = isPlaywrightRun
? createBdd(test, { worldFixture: 'world' })
: { Given: GivenC, When: WhenC, Then: ThenC };
export const Before = function (options: any, fn: StepFn) {
return isPlaywrightRun ? pwBdd.Before(options, fn) : cucumber.Before(options, fn);
};

export const { Before, After } = createBdd(test, { worldFixture: 'world' });
export const After = function (options: any, fn: StepFn) {
return isPlaywrightRun ? pwBdd.After(options, fn) : cucumber.After(options, fn);
};
2 changes: 1 addition & 1 deletion test/reuse-step-fn/features/sample.feature
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Feature: call step from step
Feature: reuse step fn

@success
Scenario: create todos
Expand Down
2 changes: 1 addition & 1 deletion test/reuse-step-fn/steps-cucumber-style/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TestInfo } from '@playwright/test';
import { test as base } from 'playwright-bdd';

type World = {
export type World = {
todos: string[];
testInfo: TestInfo;
};
Expand Down
16 changes: 6 additions & 10 deletions test/reuse-step-fn/steps-cucumber-style/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import { createBdd, DataTable } from 'playwright-bdd';
import { test } from './fixtures';
import { test, World } from './fixtures';
import { expect } from '@playwright/test';
import { expectTypeOf } from 'expect-type';

const { When, Then } = createBdd(test, { worldFixture: 'world' });

const createTodo = When('I create todo {string}', async function (text: string) {
this.todos.push(`${this.testInfo.title} - ${text}`);
});

expectTypeOf(createTodo).toBeFunction();
expectTypeOf(createTodo).thisParameter.toEqualTypeOf<World>();
expectTypeOf(createTodo).parameter(0).toEqualTypeOf<string>();

When('I create 2 todos {string} and {string}', async function (text1: string, text2: string) {
await createTodo.call(this, text1);
await createTodo.call(this, text2);
});

When(
'I incorrectly create 2 todos {string} and {string}',
async function (_text1: string, _text2: string) {
// TS automatically blocks these invalid calls!
// await createTodo(text1);
// await createTodo.call({}, text2);
},
);

Then('I see todos:', async function (data: DataTable) {
expect(this.todos).toEqual(data.rows().flat());
});
6 changes: 6 additions & 0 deletions test/reuse-step-fn/steps-pw-style/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { createBdd, DataTable } from 'playwright-bdd';
import { test } from './fixtures';
import { expect } from '@playwright/test';
import { expectTypeOf } from 'expect-type';

const { When, Then } = createBdd(test);

const createTodo = When('I create todo {string}', async ({ todos, $testInfo }, text: string) => {
todos.push(`${$testInfo.title} - ${text}`);
});

expectTypeOf(createTodo).toBeFunction();
expectTypeOf(createTodo).parameter(0).toHaveProperty('$testInfo');
expectTypeOf(createTodo).parameter(0).toHaveProperty('todos');
expectTypeOf(createTodo).parameter(1).toEqualTypeOf<string>();

When(
'I create 2 todos {string} and {string}',
async ({ todos, $testInfo }, text1: string, text2: string) => {
Expand Down

0 comments on commit ffa6a6f

Please sign in to comment.