From 9fa70cff2f0aca4231274618c114223c9bcd2236 Mon Sep 17 00:00:00 2001 From: Vitaliy Potapov Date: Fri, 25 Oct 2024 10:35:45 +0400 Subject: [PATCH] fix: scenario hooks contribute to custom test --- package-lock.json | 11 ++-- package.json | 2 +- src/gen/formatter.ts | 6 -- src/gen/hooks.ts | 65 +++++++++++++++++++ src/gen/importTestFrom.ts | 15 +++-- src/gen/testFile.ts | 38 ++--------- src/hooks/scenario.ts | 6 +- src/steps/createBdd.ts | 4 +- src/utils/index.ts | 4 ++ .../features/sample1.feature | 4 ++ .../features/sample2.feature | 5 ++ test/import-test-guess-hooks/package.json | 4 ++ .../playwright.config.ts | 11 ++++ .../import-test-guess-hooks/steps/fixtures.ts | 13 ++++ test/import-test-guess-hooks/steps/steps.ts | 11 ++++ test/import-test-guess-hooks/test.mjs | 7 ++ 16 files changed, 154 insertions(+), 52 deletions(-) create mode 100644 src/gen/hooks.ts create mode 100644 test/import-test-guess-hooks/features/sample1.feature create mode 100644 test/import-test-guess-hooks/features/sample2.feature create mode 100644 test/import-test-guess-hooks/package.json create mode 100644 test/import-test-guess-hooks/playwright.config.ts create mode 100644 test/import-test-guess-hooks/steps/fixtures.ts create mode 100644 test/import-test-guess-hooks/steps/steps.ts create mode 100644 test/import-test-guess-hooks/test.mjs diff --git a/package-lock.json b/package-lock.json index ae6fba63..4a949040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "react-dom": "18.3.1", "slugify": "1.6.6", "ts-node": "^10.9.2", - "typescript": "5.4.5", + "typescript": "5.5.4", "typescript-eslint": "8.8.1", "xml2js": "0.6.2" }, @@ -68,7 +68,7 @@ "url": "https://github.com/sponsors/vitalets" }, "peerDependencies": { - "@playwright/test": ">=1.35" + "@playwright/test": ">=1.38" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -11029,10 +11029,11 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 7fb61bf2..8ce96a58 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "react-dom": "18.3.1", "slugify": "1.6.6", "ts-node": "^10.9.2", - "typescript": "5.4.5", + "typescript": "5.5.4", "typescript-eslint": "8.8.1", "xml2js": "0.6.2" }, diff --git a/src/gen/formatter.ts b/src/gen/formatter.ts index c637e893..7247c0cb 100644 --- a/src/gen/formatter.ts +++ b/src/gen/formatter.ts @@ -127,12 +127,6 @@ export class Formatter { ]; } - scenarioHookFixtures(fixtureNames: string[]) { - if (!fixtureNames.length) return []; - const fixtures = fixtureNames.join(', '); - return [`$scenarioHookFixtures: ({ ${fixtures} }, use) => use({ ${fixtures} }),`]; - } - workerHookFixtures(fixtureNames: string[]) { if (!fixtureNames.length) return []; const fixtures = fixtureNames.join(', '); diff --git a/src/gen/hooks.ts b/src/gen/hooks.ts new file mode 100644 index 00000000..7bb56d30 --- /dev/null +++ b/src/gen/hooks.ts @@ -0,0 +1,65 @@ +/** + * Collect hooks for test file. + */ + +import { + GeneralScenarioHook, + getScenarioHooksFixtureNames, + getScenarioHooksToRun, + ScenarioHookType, +} from '../hooks/scenario'; +import { toBoolean } from '../utils'; +import { Formatter } from './formatter'; +import { TestNode } from './testNode'; + +export class Hooks { + before = new TypedHooks('before', this.formatter); + after = new TypedHooks('after', this.formatter); + + constructor(private formatter: Formatter) {} + + registerHooksForTest(node: TestNode) { + this.before.registerHooksForTest(node); + this.after.registerHooksForTest(node); + } + + getCustomTests() { + return new Set([ + ...this.before.getCustomTests(), // prettier-ignore + ...this.after.getCustomTests(), + ]); + } + + getLines() { + const lines = [ + ...this.before.getLines(), // prettier-ignore + ...this.after.getLines(), + ]; + if (lines.length) lines.push(''); + return lines; + } +} + +class TypedHooks { + hooks = new Set(); + + constructor( + private type: T, + private formatter: Formatter, + ) {} + + registerHooksForTest(node: TestNode) { + if (node.isSkipped()) return; + getScenarioHooksToRun(this.type, node.tags).forEach((hook) => this.hooks.add(hook)); + } + + getCustomTests() { + return new Set([...this.hooks].map((hook) => hook.customTest).filter(toBoolean)); + } + + getLines() { + if (!this.hooks.size) return []; + const fixtureNames = getScenarioHooksFixtureNames([...this.hooks]); + return this.formatter.scenarioHooksCall(this.type, fixtureNames); + } +} diff --git a/src/gen/importTestFrom.ts b/src/gen/importTestFrom.ts index f72af324..ce8c690b 100644 --- a/src/gen/importTestFrom.ts +++ b/src/gen/importTestFrom.ts @@ -13,19 +13,24 @@ import { StepDefinition } from '../steps/stepDefinition'; import { exit } from '../utils/exit'; import { ImportTestFrom } from './formatter'; +// todo: refactor to fill this class in-place, instead of collecting data export class ImportTestFromGuesser { - private customTestsSet = new Set(); + private customTestsSet: Set; + // eslint-disable-next-line max-params constructor( private featureUri: string, private usedStepDefinitions: Set, private usedDecoratorFixtures: Set, - ) {} + customTestsFromHooks: Set, + ) { + this.customTestsSet = new Set([...customTestsFromHooks]); + } guess(): ImportTestFrom | undefined { this.fillCustomTestsFromRegularSteps(); this.fillCustomTestsFromDecoratorSteps(); - if (!this.getUsedCustomTestsCount()) return; + if (!this.hasCustomTests()) return; if (!getExportedTestsCount()) this.throwCantGuessError(); const topmostTest = this.findTopmostTest(); const { file, varName } = this.getExportedTestInfo(topmostTest); @@ -51,8 +56,8 @@ export class ImportTestFromGuesser { }); } - private getUsedCustomTestsCount() { - return this.customTestsSet.size; + private hasCustomTests() { + return this.customTestsSet.size > 0; } private findTopmostTest() { diff --git a/src/gen/testFile.ts b/src/gen/testFile.ts index 34214f02..91303231 100644 --- a/src/gen/testFile.ts +++ b/src/gen/testFile.ts @@ -22,12 +22,6 @@ import { KeywordsMap, getKeywordsMap } from './i18n'; import { stringifyLocation, throwIf } from '../utils'; import parseTagsExpression from '@cucumber/tag-expressions'; import { TestNode } from './testNode'; -import { - GeneralScenarioHook, - getScenarioHooksFixtureNames, - getScenarioHooksToRun, - ScenarioHookType, -} from '../hooks/scenario'; import { getWorkerHooksFixtures } from '../hooks/worker'; import { LANG_EN, isEnglish } from '../config/lang'; import { BddMetaBuilder } from './bddMetaBuilder'; @@ -45,6 +39,7 @@ import { getStepTextWithKeyword } from '../features/helpers'; import { formatDuplicateStepsMessage, StepFinder } from '../steps/finder'; import { exit } from '../utils/exit'; import { StepDefinition } from '../steps/stepDefinition'; +import { Hooks } from './hooks'; type TestFileOptions = { gherkinDocument: GherkinDocumentWithPickles; @@ -61,7 +56,7 @@ export class TestFile { private gherkinDocumentQuery: GherkinDocumentQuery; private bddMetaBuilder: BddMetaBuilder; private stepFinder: StepFinder; - private testNodesToRun: TestNode[] = []; + private hooks: Hooks; public missingSteps: MissingStep[] = []; public featureUri: string; @@ -73,6 +68,7 @@ export class TestFile { this.bddMetaBuilder = new BddMetaBuilder(this.gherkinDocumentQuery); this.featureUri = this.getFeatureUri(); this.stepFinder = new StepFinder(options.config); + this.hooks = new Hooks(this.formatter); } get gherkinDocument() { @@ -115,7 +111,7 @@ export class TestFile { this.lines = [ ...this.getFileHeader(), // prettier-ignore - ...this.getScenarioHooksCalls(), + ...this.hooks.getLines(), ...suites, ...this.getTechnicalSection(), ]; @@ -146,6 +142,7 @@ export class TestFile { this.featureUri, this.usedStepDefinitions, this.usedDecoratorFixtures, + this.hooks.getCustomTests(), ).guess(); } if (!importTestFrom) return; @@ -165,7 +162,6 @@ export class TestFile { ...this.formatter.testFixture(), ...this.formatter.uriFixture(this.featureUri), ...this.bddMetaBuilder.getFixture(), - // ...this.formatter.scenarioHookFixtures(getScenarioHooksFixtures()), ...this.formatter.workerHookFixtures(getWorkerHooksFixtures()), ...(worldFixtureName ? this.formatter.worldFixture(worldFixtureName) : []), ]); @@ -218,26 +214,6 @@ export class TestFile { return this.formatter.beforeEach(title, fixtures, lines); } - private getScenarioHooksCalls() { - const lines = [ - ...this.getScenarioHooksCall('before'), // prettier-ignore - ...this.getScenarioHooksCall('after'), - ]; - if (lines.length) lines.push(''); - return lines; - } - - private getScenarioHooksCall(type: ScenarioHookType) { - const hooksToRun = new Set(); - this.testNodesToRun.forEach((node) => { - const hooksToRunForNode = getScenarioHooksToRun(type, node.tags); - hooksToRunForNode.forEach((hook) => hooksToRun.add(hook)); - }); - if (!hooksToRun.size) return []; - const fixtureNames = getScenarioHooksFixtureNames([...hooksToRun]); - return this.formatter.scenarioHooksCall(type, fixtureNames); - } - /** * Generate test.describe suite for Scenario Outline */ @@ -272,7 +248,7 @@ export class TestFile { if (this.isSkippedBySpecialTag(node)) return this.formatter.test(node, new Set(), []); const { fixtures, lines, hasMissingSteps } = this.getSteps(scenario, node.tags, exampleRow.id); this.handleMissingStepsInScenario(hasMissingSteps, node); - if (!node.isSkipped()) this.testNodesToRun.push(node); + this.hooks.registerHooksForTest(node); return this.formatter.test(node, fixtures, lines); } @@ -286,7 +262,7 @@ export class TestFile { if (this.isSkippedBySpecialTag(node)) return this.formatter.test(node, new Set(), []); const { fixtures, lines, hasMissingSteps } = this.getSteps(scenario, node.tags); this.handleMissingStepsInScenario(hasMissingSteps, node); - if (!node.isSkipped()) this.testNodesToRun.push(node); + this.hooks.registerHooksForTest(node); return this.formatter.test(node, fixtures, lines); } diff --git a/src/hooks/scenario.ts b/src/hooks/scenario.ts index f7707d15..ddc09d4b 100644 --- a/src/hooks/scenario.ts +++ b/src/hooks/scenario.ts @@ -5,7 +5,7 @@ /* eslint-disable max-depth */ import parseTagsExpression from '@cucumber/tag-expressions'; -import { KeyValue, PlaywrightLocation } from '../playwright/types'; +import { KeyValue, PlaywrightLocation, TestTypeCommon } from '../playwright/types'; import { fixtureParameterNames } from '../playwright/fixtureParameterNames'; import { callWithTimeout } from '../utils'; import { getLocationByOffset } from '../playwright/getLocationInFile'; @@ -34,6 +34,7 @@ type ScenarioHook = { fn: ScenarioHookFn; tagsExpression?: ReturnType; location: PlaywrightLocation; + customTest?: TestTypeCommon; }; /** @@ -61,7 +62,7 @@ export function scenarioHookFactory< TestFixtures extends KeyValue, WorkerFixtures extends KeyValue, World, ->(type: ScenarioHookType) { +>(type: ScenarioHookType, customTest: TestTypeCommon | undefined) { type AllFixtures = TestFixtures & WorkerFixtures; type Args = ScenarioHookDefinitionArgs; @@ -72,6 +73,7 @@ export function scenarioHookFactory< fn: getFnFromArgs(args) as ScenarioHookFn, // offset = 3 b/c this call is 3 steps below the user's code location: getLocationByOffset(3), + customTest, }); }; } diff --git a/src/steps/createBdd.ts b/src/steps/createBdd.ts index 717ffcae..2cb840a8 100644 --- a/src/steps/createBdd.ts +++ b/src/steps/createBdd.ts @@ -60,8 +60,8 @@ export function createBdd< const BeforeAll = workerHookFactory('beforeAll'); const AfterAll = workerHookFactory('afterAll'); - const Before = scenarioHookFactory('before'); - const After = scenarioHookFactory('after'); + const Before = scenarioHookFactory('before', customTest); + const After = scenarioHookFactory('after', customTest); // cucumber-style if (options && 'worldFixture' in options && options.worldFixture) { diff --git a/src/utils/index.ts b/src/utils/index.ts index 5f080092..3c12bf3a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -103,3 +103,7 @@ export function saveFileSync(filePath: string, content: string) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, content); } + +export function toBoolean(value: T): value is NonNullable { + return Boolean(value); +} diff --git a/test/import-test-guess-hooks/features/sample1.feature b/test/import-test-guess-hooks/features/sample1.feature new file mode 100644 index 00000000..9dffdc2a --- /dev/null +++ b/test/import-test-guess-hooks/features/sample1.feature @@ -0,0 +1,4 @@ +Feature: feature without hook, should import 'test' + + Scenario: scenario 1 + Given step 1 diff --git a/test/import-test-guess-hooks/features/sample2.feature b/test/import-test-guess-hooks/features/sample2.feature new file mode 100644 index 00000000..1e878527 --- /dev/null +++ b/test/import-test-guess-hooks/features/sample2.feature @@ -0,0 +1,5 @@ +@foo +Feature: feature with scenario hook, should import 'testForScenarioHook' + + Scenario: scenario 2 + Given step 2 diff --git a/test/import-test-guess-hooks/package.json b/test/import-test-guess-hooks/package.json new file mode 100644 index 00000000..61692124 --- /dev/null +++ b/test/import-test-guess-hooks/package.json @@ -0,0 +1,4 @@ +{ + "description": "This file is required for Playwright to consider this dir as a . It ensures to load 'playwright-bdd' from './test/node_modules/playwright-bdd' and output './test-results' here to avoid conflicts.", + "smoke": true +} diff --git a/test/import-test-guess-hooks/playwright.config.ts b/test/import-test-guess-hooks/playwright.config.ts new file mode 100644 index 00000000..5eacbc9a --- /dev/null +++ b/test/import-test-guess-hooks/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@playwright/test'; +import { defineBddConfig } from 'playwright-bdd'; + +const testDir = defineBddConfig({ + features: 'features', + steps: 'steps/*.ts', +}); + +export default defineConfig({ + testDir, +}); diff --git a/test/import-test-guess-hooks/steps/fixtures.ts b/test/import-test-guess-hooks/steps/fixtures.ts new file mode 100644 index 00000000..d9636d0d --- /dev/null +++ b/test/import-test-guess-hooks/steps/fixtures.ts @@ -0,0 +1,13 @@ +import { test as base, createBdd } from 'playwright-bdd'; + +export const test = base.extend<{ option1: string }>({ + option1: ['foo', { option: true }], +}); + +export const { Given } = createBdd(test); + +export const testForScenarioHook = test.extend<{ option2: string }>({ + option2: ['bar', { option: true }], +}); + +export const { Before } = createBdd(testForScenarioHook); diff --git a/test/import-test-guess-hooks/steps/steps.ts b/test/import-test-guess-hooks/steps/steps.ts new file mode 100644 index 00000000..65a6784f --- /dev/null +++ b/test/import-test-guess-hooks/steps/steps.ts @@ -0,0 +1,11 @@ +import { expect } from '@playwright/test'; +import { Given, Before } from './fixtures'; + +Given('step {int}', async ({ option1 }) => { + expect(option1).toEqual('foo'); +}); + +Before({ tags: '@foo' }, async ({ option1, option2 }) => { + expect(option1).toEqual('foo'); + expect(option2).toEqual('bar'); +}); diff --git a/test/import-test-guess-hooks/test.mjs b/test/import-test-guess-hooks/test.mjs new file mode 100644 index 00000000..a4027b21 --- /dev/null +++ b/test/import-test-guess-hooks/test.mjs @@ -0,0 +1,7 @@ +import { test, TestDir, execPlaywrightTest } from '../_helpers/index.mjs'; + +const testDir = new TestDir(import.meta); + +test(testDir.name, () => { + execPlaywrightTest(testDir.name); +});