diff --git a/.babelrc.js b/.babelrc.js index 25da7069..612f2a39 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -1,4 +1,4 @@ -module.exports = { +export default { presets: [ ['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript', diff --git a/.storybook/test-runner.ts b/.storybook/test-runner.ts index 01ac6d02..4f8fe04d 100644 --- a/.storybook/test-runner.ts +++ b/.storybook/test-runner.ts @@ -1,7 +1,7 @@ import { toMatchImageSnapshot } from 'jest-image-snapshot'; -import { getStoryContext, waitForPageReady } from '../dist'; -import type { TestRunnerConfig } from '../dist'; +import { getStoryContext, waitForPageReady } from '../dist/index.js'; +import type { TestRunnerConfig } from '../dist/index.js'; const snapshotsDir = process.env.SNAPSHOTS_DIR || '__snapshots__'; const customSnapshotsDir = `${process.cwd()}/${snapshotsDir}`; diff --git a/jest-preset.json b/jest-preset.json new file mode 100644 index 00000000..e691c540 --- /dev/null +++ b/jest-preset.json @@ -0,0 +1,10 @@ +{ + "globalSetup": "@storybook/test-runner/dist/jest-playwright-entries/setup.js", + "globalTeardown": "@storybook/test-runner/dist/jest-playwright-entries/teardown.js", + "testEnvironment": "@storybook/test-runner/dist/jest-playwright-entries", + "runner": "@storybook/test-runner/dist/jest-playwright-entries/runner.js", + "setupFilesAfterEnv": [ + "expect-playwright", + "@storybook/test-runner/dist/jest-playwright-entries/lib/extends.js" + ] +} \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index fec48d9a..3f466bbc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,11 +1,11 @@ -module.exports = { +export default { testMatch: ['**/*.test.ts'], moduleNameMapper: { - '@storybook/test-runner/playwright/global-setup': '/playwright/global-setup', - '@storybook/test-runner/playwright/global-teardown': '/playwright/global-teardown', + '@storybook/test-runner/playwright/global-setup': '/playwright/global-setup.js', + '@storybook/test-runner/playwright/global-teardown': '/playwright/global-teardown.js', '@storybook/test-runner/playwright/custom-environment': - '/playwright/custom-environment', - '@storybook/test-runner/playwright/jest-setup': '/playwright/jest-setup', - '@storybook/test-runner/playwright/transform': '/playwright/transform', + '/playwright/custom-environment.js', + '@storybook/test-runner/playwright/jest-setup': '/playwright/jest-setup.js', + '@storybook/test-runner/playwright/transform': '/playwright/transform.js', }, }; diff --git a/package.json b/package.json index 13303575..b9743b46 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,9 @@ }, "license": "MIT", "author": "shilman", + "type": "module", "main": "dist/index.js", - "module": "dist/index.mjs", + "module": "dist/index.js", "types": "dist/index.d.ts", "bin": { "test-storybook": "./dist/test-storybook.js" @@ -26,6 +27,7 @@ "dist", "README.md", "playwright", + "jest-preset.json", "*.d.ts" ], "scripts": { @@ -37,7 +39,7 @@ "release": "yarn build && auto shipit", "start": "concurrently \"yarn build:watch\" \"yarn storybook -- --quiet\"", "storybook": "storybook dev -p 6006", - "test": "jest", + "test": "vitest", "test-storybook": "node dist/test-storybook", "test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn build-storybook --quiet && npx serve storybook-static -l 6006\" \"wait-on tcp:6006 && yarn test-storybook\"", "test-storybook:ci-coverage": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn build-storybook --quiet && npx serve storybook-static -l 6006\" \"wait-on tcp:6006 && yarn test-storybook --coverage\"", @@ -55,20 +57,23 @@ "@babel/generator": "^7.22.5", "@babel/template": "^7.22.5", "@babel/types": "^7.22.5", - "@jest/types": "^29.6.3", + "@jest/types": "^30.0.1", "@swc/core": "^1.5.22", - "@swc/jest": "^0.2.23", + "@swc/jest": "^0.2.38", "expect-playwright": "^0.8.0", - "jest": "^29.6.4", - "jest-circus": "^29.6.4", - "jest-environment-node": "^29.6.4", + "jest": "^30.0.4", + "jest-circus": "^30.0.4", + "jest-environment-node": "^30.0.4", "jest-junit": "^16.0.0", - "jest-playwright-preset": "^4.0.0", - "jest-runner": "^29.6.4", + "jest-process-manager": "^0.4.0", + "jest-runner": "^30.0.4", "jest-serializer-html": "^7.1.0", - "jest-watch-typeahead": "^2.0.0", + "jest-watch-typeahead": "^3.0.1", "nyc": "^15.1.0", - "playwright": "^1.14.0" + "playwright": "^1.14.0", + "playwright-core": ">=1.2.0", + "rimraf": "^3.0.2", + "uuid": "^8.3.2" }, "devDependencies": { "@auto-it/released": "^11.1.6", @@ -76,16 +81,19 @@ "@babel/preset-env": "^7.19.4", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", - "@storybook/addon-a11y": "next", + "@storybook/addon-a11y": "^10.0.0-beta.10", "@storybook/addon-coverage": "^1.0.0", - "@storybook/addon-docs": "next", - "@storybook/react-vite": "next", - "@types/jest": "^29.0.0", - "@types/node": "^16.4.1", - "@types/node-fetch": "^2.6.11", + "@storybook/addon-docs": "^10.0.0-beta.10", + "@storybook/react-vite": "^10.0.0-beta.10", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.10", + "@types/rimraf": "^3.0.2", + "@types/uuid": "^8.3.4", + "@typescript-eslint/eslint-plugin": "5.30.7", + "@typescript-eslint/parser": "5.30.7", "@vitejs/plugin-react": "^4.0.3", + "@vitest/coverage-v8": "^3.2.4", "auto": "^11.1.6", - "babel-jest": "^29.0.0", "babel-loader": "^8.1.0", "babel-plugin-istanbul": "^6.1.1", "can-bind-to-host": "^1.1.1", @@ -93,26 +101,34 @@ "concurrently": "^7.0.0", "glob": "^10.2.2", "husky": "^8.0.0", + "jest": "^30.0.0", + "jest-circus": "^30.0.0", + "jest-environment-node": "^30.0.0", "jest-image-snapshot": "^6.2.0", + "jest-runner": "^30.0.0", "lint-staged": "^13.0.3", - "node-fetch": "^2", + "pathe": "^2.0.3", "pkg-up": "^5.0.0", + "playwright": ">=1.24.0", + "playwright-chromium": ">=1.24.0", "prettier": "^2.8.1", "react": "^17.0.1", "react-dom": "^17.0.1", "read-pkg-up": "^7.0.1", - "storybook": "next", + "storybook": "^10.0.0-beta.10", "tempy": "^1.0.1", "ts-dedent": "^2.0.0", - "ts-jest": "^29.0.0", - "tsup": "^6.5.0", - "typescript": "~4.9.4", - "vite": "^6.3.2", + "ts-jest": "^29.4.0", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vite": "^7.0.5", + "vitest": "^3.2.4", "wait-on": "^7.2.0" }, "peerDependencies": { - "storybook": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0" + "storybook": "^10.0.0 || ^10.0.0-0" }, + "packageManager": "yarn@4.5.1", "engines": { "node": ">=20.0.0" }, @@ -136,6 +152,5 @@ "react-native" ], "icon": "https://user-images.githubusercontent.com/321738/63501763-88dbf600-c4cc-11e9-96cd-94adadc2fd72.png" - }, - "packageManager": "yarn@4.5.1" + } } diff --git a/playwright/custom-environment.js b/playwright/custom-environment.js index fcf6632d..993e8757 100644 --- a/playwright/custom-environment.js +++ b/playwright/custom-environment.js @@ -1,6 +1,5 @@ -const { setupPage } = require('../dist'); - -const PlaywrightEnvironment = require('jest-playwright-preset/lib/PlaywrightEnvironment').default; +import { setupPage } from '../dist/index.js'; +import PlaywrightEnvironment from '../dist/jest-playwright-entries/test-environment.js'; class CustomEnvironment extends PlaywrightEnvironment { async setup() { @@ -17,4 +16,4 @@ class CustomEnvironment extends PlaywrightEnvironment { } } -module.exports = CustomEnvironment; +export default CustomEnvironment; diff --git a/playwright/global-setup.js b/playwright/global-setup.js deleted file mode 100644 index 4c7686f5..00000000 --- a/playwright/global-setup.js +++ /dev/null @@ -1,6 +0,0 @@ -// global-setup.js -const { globalSetup: playwrightGlobalSetup } = require('jest-playwright-preset'); - -module.exports = async function globalSetup(globalConfig) { - return playwrightGlobalSetup(globalConfig); -}; diff --git a/playwright/global-teardown.js b/playwright/global-teardown.js deleted file mode 100644 index f0335486..00000000 --- a/playwright/global-teardown.js +++ /dev/null @@ -1,7 +0,0 @@ -// global-teardown.js -const { globalTeardown: playwrightGlobalTeardown } = require('jest-playwright-preset'); - -module.exports = async function globalTeardown(globalConfig) { - // Your global teardown - await playwrightGlobalTeardown(globalConfig); -}; diff --git a/playwright/jest-setup.js b/playwright/jest-setup.js index 7e319565..492767b2 100644 --- a/playwright/jest-setup.js +++ b/playwright/jest-setup.js @@ -1,6 +1,6 @@ -const { getTestRunnerConfig, setPreVisit, setPostVisit, setupPage } = require('../dist'); +import { getTestRunnerConfig, setPreVisit, setPostVisit, setupPage } from '../dist/index.js'; -const testRunnerConfig = getTestRunnerConfig(process.env.STORYBOOK_CONFIG_DIR); +const testRunnerConfig = await getTestRunnerConfig(process.env.STORYBOOK_CONFIG_DIR); if (testRunnerConfig) { // hooks set up if (testRunnerConfig.setup) { diff --git a/playwright/test-runner-jest.config.js b/playwright/test-runner-jest.config.js index 297a9fb1..fe7a5445 100644 --- a/playwright/test-runner-jest.config.js +++ b/playwright/test-runner-jest.config.js @@ -1,4 +1,4 @@ -const { getJestConfig } = require('../dist'); +import { getJestConfig } from '../dist/index.js'; // The default Jest configuration comes from @storybook/test-runner const testRunnerConfig = getJestConfig(); @@ -6,7 +6,7 @@ const testRunnerConfig = getJestConfig(); /** * @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { +export default { ...testRunnerConfig, /** Add your own overrides below, and make sure * to merge testRunnerConfig properties with your own diff --git a/playwright/transform.js b/playwright/transform.js index b2e6b2f6..80951a4b 100644 --- a/playwright/transform.js +++ b/playwright/transform.js @@ -1,17 +1,33 @@ -const { transformSync: swcTransform } = require('@swc/core'); -const { transformPlaywright } = require('../dist'); +import { transform as swcTransform } from '@swc/core'; +import { transformPlaywright } from '../dist/index.js'; -module.exports = { - process(src, filename) { - const csfTest = transformPlaywright(src, filename); - - const result = swcTransform(csfTest, { +// Only export async version - force Jest to use it +async function processAsync(src, filename) { + try { + const csfTest = await transformPlaywright(src, filename); + // This swc transform might not be needed + const result = await swcTransform(csfTest, { filename, + isModule: true, module: { - type: 'commonjs', + type: 'es6', + }, + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + target: 'es2015', }, }); return { code: result ? result.code : src }; - }, + } catch (error) { + console.error('Transform error:', error); + return { code: src }; + } +} + +export default { + processAsync, }; diff --git a/src/config/jest-playwright.test.ts b/src/config/jest-playwright.test.ts deleted file mode 100644 index b382a6da..00000000 --- a/src/config/jest-playwright.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { getJestConfig } from './jest-playwright'; -import path from 'path'; - -describe('getJestConfig', () => { - it('returns the correct configuration 1', () => { - const jestConfig = getJestConfig(); - - expect(jestConfig).toEqual({ - rootDir: process.cwd(), - reporters: ['default'], - testMatch: [], - transform: { - '^.+\\.(story|stories)\\.[jt]sx?$': `${path.dirname( - require.resolve('@storybook/test-runner/playwright/transform') - )}/transform.js`, - '^.+\\.[jt]sx?$': path.resolve('../test-runner/node_modules/@swc/jest'), - }, - snapshotSerializers: [path.resolve('../test-runner/node_modules/jest-serializer-html')], - testEnvironmentOptions: { - 'jest-playwright': { - browsers: undefined, - collectCoverage: false, - exitOnPageError: false, - }, - }, - watchPlugins: [ - require.resolve('jest-watch-typeahead/filename'), - require.resolve('jest-watch-typeahead/testname'), - ], - watchPathIgnorePatterns: ['coverage', '.nyc_output', '.cache'], - roots: undefined, - runner: path.resolve('../test-runner/node_modules/jest-playwright-preset/runner.js'), - globalSetup: path.resolve('playwright/global-setup.js'), - globalTeardown: path.resolve('playwright/global-teardown.js'), - testEnvironment: path.resolve('playwright/custom-environment.js'), - setupFilesAfterEnv: [ - path.resolve('playwright/jest-setup.js'), - path.resolve('../test-runner/node_modules/expect-playwright/lib'), - path.resolve('../test-runner/node_modules/jest-playwright-preset/lib/extends.js'), - ], - }); - }); - - it('parses TEST_BROWSERS environment variable correctly', () => { - interface JestPlaywrightOptions { - browsers?: string[]; - collectCoverage?: boolean; - } - process.env.TEST_BROWSERS = 'chromium, firefox, webkit'; - - const jestConfig: { - testEnvironmentOptions?: { - 'jest-playwright'?: JestPlaywrightOptions; - }; - } = getJestConfig(); - - expect(jestConfig.testEnvironmentOptions?.['jest-playwright']?.browsers as string[]).toEqual([ - 'chromium', - 'firefox', - 'webkit', - ]); - }); - - it('sets TEST_MATCH environment variable correctly', () => { - process.env.TEST_MATCH = '**/*.test.js'; - - const jestConfig = getJestConfig(); - - expect(jestConfig.testMatch).toEqual(['**/*.test.js']); - }); - - it('returns the correct configuration 2', () => { - process.env.STORYBOOK_JUNIT = 'true'; - - const jestConfig = getJestConfig(); - - expect(jestConfig.reporters).toEqual(['default', path.dirname(require.resolve('jest-junit'))]); - expect(jestConfig).toMatchObject({ - rootDir: process.cwd(), - roots: undefined, - testMatch: ['**/*.test.js'], - transform: { - '^.+\\.(story|stories)\\.[jt]sx?$': `${path.dirname( - require.resolve('@storybook/test-runner/playwright/transform') - )}/transform.js`, - '^.+\\.[jt]sx?$': path.dirname(require.resolve('@swc/jest')), - }, - snapshotSerializers: [path.dirname(require.resolve('jest-serializer-html'))], - testEnvironmentOptions: { - 'jest-playwright': { - browsers: ['chromium', 'firefox', 'webkit'], - collectCoverage: false, - }, - }, - watchPlugins: [ - require.resolve('jest-watch-typeahead/filename'), - require.resolve('jest-watch-typeahead/testname'), - ], - watchPathIgnorePatterns: ['coverage', '.nyc_output', '.cache'], - }); - }); - - it('returns the correct configuration 3', () => { - process.env.TEST_ROOT = 'test'; - process.env.STORYBOOK_STORIES_PATTERN = '**/*.stories.tsx'; - - const jestConfig = getJestConfig(); - - expect(jestConfig).toMatchObject({ - roots: ['test'], - reporters: ['default', path.resolve('../test-runner/node_modules/jest-junit')], - testMatch: ['**/*.test.js'], - }); - }); -}); diff --git a/src/config/jest-playwright.ts b/src/config/jest-playwright.ts index 4fab6d3f..d07215bb 100644 --- a/src/config/jest-playwright.ts +++ b/src/config/jest-playwright.ts @@ -1,42 +1,36 @@ -import path from 'path'; +import path from 'pathe'; import { getProjectRoot } from 'storybook/internal/common'; import type { Config } from '@jest/types'; -const getTestRunnerPath = () => process.env.STORYBOOK_TEST_RUNNER_PATH ?? '@storybook/test-runner'; +const getTestRunnerPath = () => { + return ( + process.env.STORYBOOK_TEST_RUNNER_PATH ?? + path.dirname(require.resolve('@storybook/test-runner/package.json')) + ); +}; /** * IMPORTANT NOTE: * Depending on the user's project and package manager, it's possible that jest-playwright-preset * is going to be located in @storybook/test-runner/node_modules OR in the root node_modules * - * By setting `preset: 'jest-playwright-preset` the change of resolution issues is higher, because + * By setting `preset: 'jest-playwright-preset' the change of resolution issues is higher, because * the lib might be installed inside of @storybook/test-runner/node_modules but references as if it was * in the root node_modules. * - * This function does the same thing as `preset: 'jest-playwright-preset` but makes sure that the + * This function does the same thing as `preset: 'jest-playwright-preset' but makes sure that the * necessary moving parts are all required within the correct path. * */ const getJestPlaywrightConfig = (): Config.InitialOptions => { - const TEST_RUNNER_PATH = getTestRunnerPath(); - const presetBasePath = path.dirname( - require.resolve('jest-playwright-preset', { - paths: [path.join(__dirname, '../node_modules')], - }) - ); - const expectPlaywrightPath = path.dirname( - require.resolve('expect-playwright', { - paths: [path.join(__dirname, '../node_modules')], - }) - ); + const resolvedRunnerPath = getTestRunnerPath(); + const expectPlaywrightPath = path.dirname(require.resolve('expect-playwright')); return { - runner: path.join(presetBasePath, 'runner.js'), - globalSetup: require.resolve(`${TEST_RUNNER_PATH}/playwright/global-setup.js`), - globalTeardown: require.resolve(`${TEST_RUNNER_PATH}/playwright/global-teardown.js`), - testEnvironment: require.resolve(`${TEST_RUNNER_PATH}/playwright/custom-environment.js`), + runner: path.join(resolvedRunnerPath, 'dist/jest-playwright-entries/runner.js'), + testEnvironment: path.join(resolvedRunnerPath, 'playwright/custom-environment.js'), setupFilesAfterEnv: [ - require.resolve(`${TEST_RUNNER_PATH}/playwright/jest-setup.js`), + path.join(resolvedRunnerPath, 'playwright/jest-setup.js'), expectPlaywrightPath, - path.join(presetBasePath, 'lib', 'extends.js'), + path.join(resolvedRunnerPath, 'dist/jest-playwright-entries/extends.js'), ], }; }; @@ -51,23 +45,11 @@ export const getJestConfig = (): Config.InitialOptions => { STORYBOOK_JUNIT, } = process.env; - const jestJunitPath = path.dirname( - require.resolve('jest-junit', { - paths: [path.join(__dirname, '../node_modules')], - }) - ); + const jestJunitPath = path.dirname(require.resolve('jest-junit')); - const jestSerializerHtmlPath = path.dirname( - require.resolve('jest-serializer-html', { - paths: [path.join(__dirname, '../node_modules')], - }) - ); + const jestSerializerHtmlPath = path.dirname(require.resolve('jest-serializer-html')); - const swcJestPath = path.dirname( - require.resolve('@swc/jest', { - paths: [path.join(__dirname, '../node_modules')], - }) - ); + const swcJestPath = path.dirname(require.resolve('@swc/jest')); const reporters = STORYBOOK_JUNIT ? ['default', jestJunitPath] : ['default']; @@ -81,10 +63,11 @@ export const getJestConfig = (): Config.InitialOptions => { testMatch, transform: { '^.+\\.(story|stories)\\.[jt]sx?$': require.resolve( - `${TEST_RUNNER_PATH}/playwright/transform` + `${TEST_RUNNER_PATH}/playwright/transform.js` ), '^.+\\.[jt]sx?$': swcJestPath, }, + extensionsToTreatAsEsm: ['.jsx', '.ts', '.tsx'], snapshotSerializers: [jestSerializerHtmlPath], testEnvironmentOptions: { 'jest-playwright': { diff --git a/src/csf/__snapshots__/transformCsf.test.ts.snap b/src/csf/__snapshots__/transformCsf.test.ts.snap index 7fff8827..4e81dc86 100644 --- a/src/csf/__snapshots__/transformCsf.test.ts.snap +++ b/src/csf/__snapshots__/transformCsf.test.ts.snap @@ -1,6 +1,6 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`transformCsf calls the beforeEachPrefixer function once 1`] = ` +exports[`transformCsf > calls the beforeEachPrefixer function once 1`] = ` " export default { title: 'Button', @@ -16,73 +16,71 @@ exports[`transformCsf calls the beforeEachPrefixer function once 1`] = ` export const Primary = () => ''; -if (!require.main) { - describe("Button", () => { - describe("Primary", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "button--primary", - title: "Button", - name: "Primary" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "button--primary" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; +describe("Button", () => { + describe("Primary", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "button--primary", + title: "Button", + name: "Primary" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "button--primary" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Button"}/\${"Primary"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: +The code in this story is not instrumented, which means the coverage setup is likely not correct. +More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Button"}/\${"Primary"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } }); }); -}" +});" `; -exports[`transformCsf calls the testPrefixer function for each test 1`] = ` +exports[`transformCsf > calls the testPrefixer function for each test 1`] = ` " export default { title: 'Button', @@ -98,141 +96,137 @@ exports[`transformCsf calls the testPrefixer function for each test 1`] = ` export const Primary = () => ''; -if (!require.main) { - describe("Button", () => { - describe("Primary", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "button--primary", - title: "Button", - name: "Primary" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "button--primary" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; +describe("Button", () => { + describe("Primary", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "button--primary", + title: "Button", + name: "Primary" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "button--primary" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Button"}/\${"Primary"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: +The code in this story is not instrumented, which means the coverage setup is likely not correct. +More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Button"}/\${"Primary"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } }); }); -}" +});" `; -exports[`transformCsf clears the body if clearBody option is true 1`] = ` +exports[`transformCsf > clears the body if clearBody option is true 1`] = ` " -if (!require.main) { - describe("Button", () => { - describe("Primary", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "button--primary", - title: "Button", - name: "Primary" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "button--primary" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; +describe("Button", () => { + describe("Primary", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "button--primary", + title: "Button", + name: "Primary" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "button--primary" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Button"}/\${"Primary"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: +The code in this story is not instrumented, which means the coverage setup is likely not correct. +More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Button"}/\${"Primary"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } }); }); -}" +});" `; -exports[`transformCsf executes beforeEach code before each test 1`] = ` +exports[`transformCsf > executes beforeEach code before each test 1`] = ` " export default { title: 'Button', @@ -248,19 +242,17 @@ exports[`transformCsf executes beforeEach code before each test 1`] = ` export const Primary = () => ''; -if (!require.main) { - describe("Button", () => { - beforeEach(beforeEach(() => { - console.log("beforeEach called"); - })); - describe("Primary", () => { - it("smoke-test", async () => {}); - }); +describe("Button", () => { + beforeEach(beforeEach(() => { + console.log("beforeEach called"); + })); + describe("Primary", () => { + it("smoke-test", async () => {}); }); -}" +});" `; -exports[`transformCsf returns empty result if there are no stories 1`] = ` +exports[`transformCsf > returns empty result if there are no stories 1`] = ` " export default { title: 'Button', diff --git a/src/csf/transformCsf.test.ts b/src/csf/transformCsf.test.ts index f48379d3..8c3b3fe4 100644 --- a/src/csf/transformCsf.test.ts +++ b/src/csf/transformCsf.test.ts @@ -1,9 +1,10 @@ import { TestPrefixer, TransformOptions, transformCsf } from './transformCsf'; import { testPrefixer } from '../playwright/transformPlaywright'; import template from '@babel/template'; +import { describe, it, expect } from 'vitest'; -describe('transformCsf', () => { - it('inserts a no-op test if there are no stories', () => { +describe('transformCsf', async () => { + it('inserts a no-op test if there are no stories', async () => { const csfCode = ` export default { title: 'Button', @@ -11,24 +12,24 @@ describe('transformCsf', () => { `; const expectedCode = `describe.skip('Button', () => { it('no-op', () => {}) });`; - const result = transformCsf(csfCode, { insertTestIfEmpty: true } as TransformOptions); + const result = await transformCsf(csfCode, { insertTestIfEmpty: true } as TransformOptions); expect(result).toEqual(expectedCode); }); - it('returns empty result if there are no stories', () => { + it('returns empty result if there are no stories', async () => { const csfCode = ` export default { title: 'Button', }; `; - const result = transformCsf(csfCode, { testPrefixer }); + const result = await transformCsf(csfCode, { testPrefixer }); expect(result).toMatchSnapshot(); }); - it('calls the testPrefixer function for each test', () => { + it('calls the testPrefixer function for each test', async () => { const csfCode = ` export default { title: 'Button', @@ -44,12 +45,12 @@ describe('transformCsf', () => { export const Primary = () => ''; `; - const result = transformCsf(csfCode, { testPrefixer }); + const result = await transformCsf(csfCode, { testPrefixer }); expect(result).toMatchSnapshot(); }); - it('calls the beforeEachPrefixer function once', () => { + it('calls the beforeEachPrefixer function once', async () => { const csfCode = ` export default { title: 'Button', @@ -64,12 +65,12 @@ describe('transformCsf', () => { }; export const Primary = () => ''; `; - const result = transformCsf(csfCode, { testPrefixer, beforeEachPrefixer: undefined }); + const result = await transformCsf(csfCode, { testPrefixer, beforeEachPrefixer: undefined }); expect(result).toMatchSnapshot(); }); - it('clears the body if clearBody option is true', () => { + it('clears the body if clearBody option is true', async () => { const csfCode = ` export default { title: 'Button', @@ -85,12 +86,12 @@ describe('transformCsf', () => { export const Primary = () => ''; `; - const result = transformCsf(csfCode, { testPrefixer, clearBody: true }); + const result = await transformCsf(csfCode, { testPrefixer, clearBody: true }); expect(result).toMatchSnapshot(); }); - it('executes beforeEach code before each test', () => { + it('executes beforeEach code before each test', async () => { const code = ` export default { title: 'Button', @@ -114,7 +115,10 @@ describe('transformCsf', () => { console.log({ id: %%id%%, title: %%title%%, name: %%name%%, storyExport: %%storyExport%% }); async () => {}`) as unknown as TestPrefixer; - const result = transformCsf(code, { beforeEachPrefixer, testPrefixer } as TransformOptions); + const result = await transformCsf(code, { + beforeEachPrefixer, + testPrefixer, + } as TransformOptions); expect(result).toMatchSnapshot(); }); diff --git a/src/csf/transformCsf.ts b/src/csf/transformCsf.ts index c004a16e..bb11c7b9 100644 --- a/src/csf/transformCsf.ts +++ b/src/csf/transformCsf.ts @@ -2,11 +2,14 @@ import { toId, storyNameFromExport, combineTags } from 'storybook/internal/csf'; import { loadCsf } from 'storybook/internal/csf-tools'; import * as t from '@babel/types'; -import generate from '@babel/generator'; +import babelGenerate from '@babel/generator'; import dedent from 'ts-dedent'; import { getTagOptions } from '../util/getTagOptions'; +// Handle both ESM and CJS patterns +const generate = (babelGenerate as any).default ?? babelGenerate; + export interface TestContext { storyExport?: t.Identifier; name: t.Literal; @@ -100,7 +103,7 @@ const makeBeforeEach = (beforeEachPrefixer: FilePrefixer) => { const makeArray = (templateResult: TemplateResult) => Array.isArray(templateResult) ? templateResult : [templateResult]; -export const transformCsf = ( +export const transformCsf = async ( code: string, { clearBody = false, @@ -111,7 +114,7 @@ export const transformCsf = ( previewAnnotations = { tags: [] }, }: TransformOptions & { previewAnnotations?: Record } ) => { - const { includeTags, excludeTags, skipTags } = getTagOptions(); + const { includeTags, excludeTags, skipTags } = await getTagOptions(); const csf = loadCsf(code, { makeTitle: makeTitle ?? ((userTitle: string) => userTitle) }); csf.parse(); @@ -124,8 +127,7 @@ export const transformCsf = ( const annotations = csf._storyAnnotations[key]; acc[key] = {}; if (annotations?.play) { - // @ts-expect-error type mismatch – check later - acc[key].play = annotations.play; + acc[key].play = annotations.play as t.Node; } acc[key].tags = combineTags( @@ -188,9 +190,7 @@ export const transformCsf = ( const { code: describeCode } = generate(describe, {}); result = dedent` ${result} - if (!require.main) { - ${describeCode} - } + ${describeCode} `; } else if (insertTestIfEmpty) { // When there are no tests at all, we skip. The reason is that the file already went through Jest's transformation, diff --git a/src/jest-playwright-entries/extends.ts b/src/jest-playwright-entries/extends.ts new file mode 100644 index 00000000..4784529b --- /dev/null +++ b/src/jest-playwright-entries/extends.ts @@ -0,0 +1,117 @@ +/* global jestPlaywright, browserName, deviceName */ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +import { getSkipFlag, deepMerge } from '../jest-playwright-preset/utils'; +import { + JestPlaywrightGlobal, + SkipOption, + TestPlaywrightConfigOptions, +} from '../jest-playwright-preset/types'; +import { CONFIG_ENVIRONMENT_NAME, DEBUG_TIMEOUT } from '../jest-playwright-preset/constants'; + +declare const global: JestPlaywrightGlobal; + +type TestType = 'it' | 'describe'; + +const DEBUG_OPTIONS = { + launchOptions: { + headless: false, + devtools: true, + }, +}; + +const runDebugTest = (jestTestType: jest.It, ...args: any[]) => { + const isConfigProvided = typeof args[0] === 'object'; + const lastArg = args[args.length - 1]; + const timer = typeof lastArg === 'number' ? lastArg : DEBUG_TIMEOUT; + // TODO Looks weird - need to be rewritten + let options = DEBUG_OPTIONS as TestPlaywrightConfigOptions; + if (isConfigProvided) { + options = deepMerge(DEBUG_OPTIONS, args[0]); + } + + jestTestType( + args[isConfigProvided ? 1 : 0], + async () => { + const envArgs = await jestPlaywright.configSeparateEnv(options, true); + try { + await args[isConfigProvided ? 2 : 1](envArgs); + } finally { + await envArgs.browser!.close(); + } + }, + timer + ); +}; + +// @ts-ignore +it.jestPlaywrightDebug = (...args) => { + runDebugTest(it, ...args); +}; + +it.jestPlaywrightDebug.only = (...args: any[]) => { + runDebugTest(it.only, ...args); +}; + +it.jestPlaywrightDebug.skip = (...args: any[]) => { + runDebugTest(it.skip, ...args); +}; + +const runConfigTest = ( + jestTypeTest: jest.It, + playwrightOptions: Partial, + ...args: any[] +) => { + const lastArg = args[args.length - 1]; + const timer = typeof lastArg === 'number' ? lastArg : global[CONFIG_ENVIRONMENT_NAME].testTimeout; + jestTypeTest( + args[0], + async () => { + const envArgs = await jestPlaywright.configSeparateEnv(playwrightOptions); + try { + await args[1](envArgs); + } finally { + await envArgs.browser!.close(); + } + }, + timer + ); +}; + +//@ts-ignore +it.jestPlaywrightConfig = (playwrightOptions, ...args) => { + runConfigTest(it, playwrightOptions, ...args); +}; + +it.jestPlaywrightConfig.only = (...args) => { + runConfigTest(it.only, ...args); +}; + +it.jestPlaywrightConfig.skip = (...args) => { + runConfigTest(it.skip, ...args); +}; + +const customSkip = (skipOption: SkipOption, type: TestType, ...args: any[]) => { + const skipFlag = getSkipFlag(skipOption, browserName, deviceName); + if (skipFlag) { + // @ts-ignore + global[type].skip(...args); + } else { + // @ts-ignore + global[type](...args); + } +}; + +it.jestPlaywrightSkip = (skipOption, ...args) => { + customSkip(skipOption, 'it', ...args); +}; + +//@ts-ignore +describe.jestPlaywrightSkip = (skipOption: SkipOption, ...args) => { + customSkip(skipOption, 'describe', ...args); +}; + +beforeEach(async () => { + if (global[CONFIG_ENVIRONMENT_NAME].resetContextPerTest) { + await jestPlaywright.resetContext(); + } +}); diff --git a/src/jest-playwright-entries/runner.ts b/src/jest-playwright-entries/runner.ts new file mode 100644 index 00000000..755ab00d --- /dev/null +++ b/src/jest-playwright-entries/runner.ts @@ -0,0 +1,3 @@ +import PlaywrightRunner from '../jest-playwright-preset/PlaywrightRunner'; + +export default PlaywrightRunner; diff --git a/src/jest-playwright-entries/setup.ts b/src/jest-playwright-entries/setup.ts new file mode 100644 index 00000000..57dfb02f --- /dev/null +++ b/src/jest-playwright-entries/setup.ts @@ -0,0 +1,3 @@ +import { setup } from '../jest-playwright-preset/global'; + +export default setup; diff --git a/src/jest-playwright-entries/teardown.ts b/src/jest-playwright-entries/teardown.ts new file mode 100644 index 00000000..f9b03eb0 --- /dev/null +++ b/src/jest-playwright-entries/teardown.ts @@ -0,0 +1,3 @@ +import { teardown } from '../jest-playwright-preset/global'; + +export default teardown; diff --git a/src/jest-playwright-entries/test-environment.ts b/src/jest-playwright-entries/test-environment.ts new file mode 100644 index 00000000..9662a012 --- /dev/null +++ b/src/jest-playwright-entries/test-environment.ts @@ -0,0 +1,3 @@ +import PlaywrightEnvironment from '../jest-playwright-preset/PlaywrightEnvironment'; + +export default PlaywrightEnvironment; diff --git a/src/jest-playwright-preset/PlaywrightEnvironment.ts b/src/jest-playwright-preset/PlaywrightEnvironment.ts new file mode 100644 index 00000000..fefe5627 --- /dev/null +++ b/src/jest-playwright-preset/PlaywrightEnvironment.ts @@ -0,0 +1,356 @@ +/* eslint-disable no-console, @typescript-eslint/no-unused-vars */ +import type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright-core'; +import { Event } from 'jest-circus'; +import type { JestEnvironmentConfig } from '@jest/environment'; +import type { + BrowserType, + ConfigDeviceType, + ConfigParams, + GenericBrowser, + JestPlaywrightConfig, + JestPlaywrightProjectConfig, + Nullable, + Playwright, + TestPlaywrightConfigOptions, +} from './types'; +import { + CHROMIUM, + CONFIG_ENVIRONMENT_NAME, + DEFAULT_CONFIG, + FIREFOX, + IMPORT_KIND_PLAYWRIGHT, + PERSISTENT, + LAUNCH, +} from './constants'; +import { + checkDevice, + deepMerge, + formatError, + getBrowserOptions, + getBrowserType, + getDeviceBrowserType, + getPlaywrightInstance, +} from './utils'; +import { saveCoverageOnPage, saveCoverageToFile } from './coverage'; + +const handleError = (error: Error): void => { + process.emit('uncaughtException', error); +}; + +const getBrowserPerProcess = async ( + playwrightInstance: GenericBrowser, + browserType: BrowserType, + config: JestPlaywrightConfig +): Promise => { + const { launchType, userDataDir, launchOptions, connectOptions } = config; + + if (launchType === LAUNCH || launchType === PERSISTENT) { + // https://github.com/playwright-community/jest-playwright/issues/42#issuecomment-589170220 + if (browserType !== CHROMIUM && launchOptions?.args) { + launchOptions.args = launchOptions.args.filter((item: string) => item !== '--no-sandbox'); + } + + const options = getBrowserOptions(browserType, launchOptions); + + if (launchType === LAUNCH) { + return playwrightInstance.launch(options); + } + + if (launchType === PERSISTENT) { + return playwrightInstance.launchPersistentContext(userDataDir!, options); + } + } + + const options = getBrowserOptions(browserType, connectOptions); + return options && 'endpointURL' in options + ? playwrightInstance.connectOverCDP(options) + : playwrightInstance.connect(options); +}; + +const getDeviceConfig = ( + device: Nullable | undefined, + availableDevices: Playwright['devices'] +): BrowserContextOptions => { + if (device) { + if (typeof device === 'string') { + const { defaultBrowserType, ...deviceProps } = availableDevices[device]; + return deviceProps; + } else { + const { name, defaultBrowserType, ...deviceProps } = device; + return deviceProps; + } + } + return {}; +}; + +const getDeviceName = (device: Nullable): Nullable => { + let deviceName: Nullable = null; + if (device != null) { + if (typeof device === 'string') { + deviceName = device; + } else { + deviceName = device.name; + } + } + return deviceName; +}; + +export const getPlaywrightEnv = (): unknown => { + const RootEnv = require('jest-environment-node').default; + + return class PlaywrightEnvironment extends RootEnv { + readonly _config: JestPlaywrightProjectConfig; + _jestPlaywrightConfig!: JestPlaywrightConfig; + + constructor(config: JestEnvironmentConfig) { + super(config); + this._config = config.projectConfig as JestPlaywrightProjectConfig; + } + + _getContextOptions(devices: Playwright['devices']): BrowserContextOptions { + const { browserName, device } = this._config; + const browserType = getBrowserType(browserName); + const { contextOptions } = this._jestPlaywrightConfig; + const deviceBrowserContextOptions = getDeviceConfig(device, devices); + const resultContextOptions = deepMerge( + deviceBrowserContextOptions, + getBrowserOptions(browserName, contextOptions) + ); + if (browserType === FIREFOX && resultContextOptions.isMobile) { + console.warn(formatError(`isMobile is not supported in ${FIREFOX}.`)); + delete resultContextOptions.isMobile; + } + return resultContextOptions; + } + + _getSeparateEnvBrowserConfig( + isDebug: boolean, + config: TestPlaywrightConfigOptions + ): JestPlaywrightConfig { + const { debugOptions } = this._jestPlaywrightConfig; + const defaultBrowserConfig: JestPlaywrightConfig = { + ...DEFAULT_CONFIG, + launchType: LAUNCH, + }; + let resultBrowserConfig: JestPlaywrightConfig = { + ...defaultBrowserConfig, + ...config, + }; + if (isDebug) { + if (debugOptions) { + resultBrowserConfig = deepMerge(resultBrowserConfig, debugOptions); + } + } else { + resultBrowserConfig = deepMerge(this._jestPlaywrightConfig, resultBrowserConfig); + } + return resultBrowserConfig; + } + + _getSeparateEnvContextConfig( + isDebug: boolean, + config: TestPlaywrightConfigOptions, + browserName: BrowserType, + devices: Playwright['devices'] + ): BrowserContextOptions { + const { device, contextOptions } = config; + const { debugOptions } = this._jestPlaywrightConfig; + const deviceContextOptions: BrowserContextOptions = getDeviceConfig(device, devices); + let resultContextOptions: BrowserContextOptions = contextOptions || {}; + if (isDebug) { + if (debugOptions?.contextOptions) { + resultContextOptions = deepMerge(resultContextOptions, debugOptions.contextOptions!); + } + } else { + resultContextOptions = deepMerge( + this._jestPlaywrightConfig.contextOptions!, + resultContextOptions + ); + } + resultContextOptions = deepMerge(deviceContextOptions, resultContextOptions); + return getBrowserOptions(browserName, resultContextOptions); + } + + async _setNewPageInstance(context = this.global.context) { + const { exitOnPageError } = this._jestPlaywrightConfig; + const page = await context.newPage(); + if (exitOnPageError) { + page.on('pageerror', handleError); + } + return page; + } + + async _setCollectCoverage(context: BrowserContext) { + await context.exposeFunction('reportCodeCoverage', (coverage: unknown) => { + if (coverage) saveCoverageToFile(coverage); + }); + await context.addInitScript(() => + window.addEventListener('beforeunload', () => { + // @ts-ignore + reportCodeCoverage(window.__coverage__); + }) + ); + } + + async setup(): Promise { + const { wsEndpoint, browserName, testEnvironmentOptions } = this._config; + this._jestPlaywrightConfig = testEnvironmentOptions[ + CONFIG_ENVIRONMENT_NAME + ] as JestPlaywrightConfig; + const { connectOptions, collectCoverage, selectors, launchType, skipInitialization } = + this._jestPlaywrightConfig; + if (wsEndpoint) { + this._jestPlaywrightConfig.connectOptions = { + ...connectOptions, + wsEndpoint, + }; + } + const browserType = getBrowserType(browserName); + const device = this._config.device; + const deviceName: Nullable = getDeviceName(device); + const { name, instance: playwrightInstance, devices } = getPlaywrightInstance(browserType); + const contextOptions = this._getContextOptions(devices); + + if (name === IMPORT_KIND_PLAYWRIGHT && selectors) { + const playwright = require('playwright'); + await Promise.all( + selectors.map(({ name, script }) => + playwright.selectors.register(name, script).catch((e: Error): void => { + if (!e.toString().includes('has been already')) { + throw e; + } + }) + ) + ); + } + + this.global.browserName = browserType; + this.global.deviceName = deviceName; + if (!skipInitialization) { + const browserOrContext = await getBrowserPerProcess( + playwrightInstance as GenericBrowser, + browserType, + this._jestPlaywrightConfig + ); + this.global.browser = launchType === PERSISTENT ? null : browserOrContext; + this.global.context = + launchType === PERSISTENT + ? browserOrContext + : await this.global.browser.newContext(contextOptions); + if (collectCoverage) { + await this._setCollectCoverage(this.global.context as BrowserContext); + } + this.global.page = await this._setNewPageInstance(); + } + this.global.jestPlaywright = { + configSeparateEnv: async ( + config: TestPlaywrightConfigOptions, + isDebug = false + ): Promise => { + const { device } = config; + const browserName = + config.useDefaultBrowserType && device + ? getDeviceBrowserType(device, devices) || CHROMIUM + : config.browser || browserType; + const deviceName = device ? getDeviceName(device) : null; + checkDevice(deviceName, devices); + const resultBrowserConfig: JestPlaywrightConfig = this._getSeparateEnvBrowserConfig( + isDebug, + config + ); + const resultContextOptions: BrowserContextOptions = this._getSeparateEnvContextConfig( + isDebug, + config, + browserName, + devices + ); + const { instance } = getPlaywrightInstance(browserName); + const browser = await getBrowserPerProcess( + instance as GenericBrowser, + browserName, + resultBrowserConfig + ); + const context = await (browser as Browser)!.newContext(resultContextOptions); + const page = await context!.newPage(); + return { browserName, deviceName, browser, context, page }; + }, + resetPage: async (): Promise => { + await this.global.page?.close(); + this.global.page = await this._setNewPageInstance(); + }, + resetContext: async (newOptions?: BrowserContextOptions): Promise => { + const { browser, context } = this.global; + await context?.close(); + + const newContextOptions = newOptions + ? deepMerge(contextOptions, newOptions) + : contextOptions; + + this.global.context = await browser.newContext(newContextOptions); + this.global.page = await this._setNewPageInstance(); + }, + resetBrowser: async (newOptions?: BrowserContextOptions): Promise => { + const { browser } = this.global; + await browser?.close(); + + this.global.browser = await getBrowserPerProcess( + playwrightInstance as GenericBrowser, + browserType, + this._jestPlaywrightConfig + ); + + const newContextOptions = newOptions + ? deepMerge(contextOptions, newOptions) + : contextOptions; + + this.global.context = await this.global.browser.newContext(newContextOptions); + this.global.page = await this._setNewPageInstance(); + }, + saveCoverage: async (page: Page): Promise => + saveCoverageOnPage(page, collectCoverage), + }; + } + + async handleTestEvent(event: Event) { + const { browserName } = this._config; + const { collectCoverage, haveSkippedTests } = this._jestPlaywrightConfig; + const browserType = getBrowserType(browserName); + const { instance, devices } = getPlaywrightInstance(browserType); + const contextOptions = this._getContextOptions(devices); + if (haveSkippedTests && event.name === 'run_start') { + this.global.browser = await getBrowserPerProcess( + instance as GenericBrowser, + browserType, + this._jestPlaywrightConfig + ); + this.global.context = await this.global.browser.newContext(contextOptions); + if (collectCoverage) { + await this._setCollectCoverage(this.global.context); + } + this.global.page = await this._setNewPageInstance(); + } + } + + async teardown(): Promise { + const { browser, context, page } = this.global; + const { collectCoverage } = this._jestPlaywrightConfig; + page?.removeListener('pageerror', handleError); + if (collectCoverage) { + await Promise.all( + (context as BrowserContext).pages().map((p) => + p.close({ + runBeforeUnload: true, + }) + ) + ); + // wait until coverage data was sent successfully to the exposed function + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + await browser?.close(); + + await super.teardown(); + } + }; +}; + +export default getPlaywrightEnv(); diff --git a/src/jest-playwright-preset/PlaywrightRunner.ts b/src/jest-playwright-preset/PlaywrightRunner.ts new file mode 100644 index 00000000..9637b76b --- /dev/null +++ b/src/jest-playwright-preset/PlaywrightRunner.ts @@ -0,0 +1,203 @@ +import JestRunner from 'jest-runner'; +import type { BrowserServer } from 'playwright-core'; +import type { Test, TestRunnerContext, TestWatcher, TestRunnerOptions } from 'jest-runner'; +import type { Config as JestConfig } from '@jest/types'; +import type { + BrowserType, + BrowserTest, + DeviceType, + WsEndpointType, + JestPlaywrightTest, + JestPlaywrightConfig, + ConfigDeviceType, + Playwright, +} from './types'; +import { + checkBrowserEnv, + checkDevice, + getDisplayName, + readConfig, + getPlaywrightInstance, + getBrowserOptions, + getBrowserType, + getDeviceBrowserType, + deepMerge, + generateKey, +} from './utils'; +import { + DEBUG_TIMEOUT, + DEFAULT_TEST_PLAYWRIGHT_TIMEOUT, + CONFIG_ENVIRONMENT_NAME, + SERVER, + LAUNCH, +} from './constants'; +import { setupCoverage, mergeCoverage } from './coverage'; +import { GenericBrowser } from './types'; + +const getBrowserTest = ({ + test, + config, + browser, + wsEndpoint, + device, + testTimeout, +}: BrowserTest): JestPlaywrightTest => { + const { displayName, testEnvironmentOptions } = test.context.config; + const playwrightDisplayName = getDisplayName(config.displayName || browser, device); + return { + ...test, + context: { + ...test.context, + config: { + ...test.context.config, + testEnvironmentOptions: { + ...testEnvironmentOptions, + [CONFIG_ENVIRONMENT_NAME]: { ...config, testTimeout }, + }, + browserName: browser, + wsEndpoint, + device, + displayName: { + name: displayName + ? `${playwrightDisplayName} ${displayName.name || displayName}` + : playwrightDisplayName, + color: displayName?.color || 'yellow', + }, + }, + }, + }; +}; + +const getDevices = ( + devices: JestPlaywrightConfig['devices'], + availableDevices: Playwright['devices'] +) => { + let resultDevices: ConfigDeviceType[] = []; + + if (devices) { + if (devices instanceof RegExp) { + resultDevices = Object.keys(availableDevices).filter((item) => item.match(devices)); + } else { + resultDevices = devices; + } + } + + return resultDevices; +}; + +const getJestTimeout = (configTimeout?: number) => { + if (configTimeout) { + return configTimeout; + } + return process.env.PWDEBUG ? DEBUG_TIMEOUT : DEFAULT_TEST_PLAYWRIGHT_TIMEOUT; +}; + +class PlaywrightRunner extends JestRunner { + browser2Server: Partial>; + config: JestConfig.GlobalConfig; + constructor(globalConfig: JestConfig.GlobalConfig, context: TestRunnerContext) { + const config = { ...globalConfig }; + // Set testTimeout + config.testTimeout = getJestTimeout(config.testTimeout); + super(config, context); + this.browser2Server = {}; + this.config = config; + } + + async launchServer( + config: JestPlaywrightConfig, + wsEndpoint: WsEndpointType, + browser: BrowserType, + key: string, + instance: GenericBrowser + ): Promise { + const { launchType, launchOptions, skipInitialization } = config; + if (!skipInitialization && launchType === SERVER && wsEndpoint === null) { + if (!this.browser2Server[key]) { + const options = getBrowserOptions(browser, launchOptions); + this.browser2Server[key] = await instance.launchServer(options); + } + } + return wsEndpoint || this.browser2Server[key]?.wsEndpoint() || null; + } + + async getTests(tests: Test[], config: JestPlaywrightConfig): Promise { + const { browsers, devices, connectOptions, useDefaultBrowserType } = config; + const pwTests: Test[] = []; + for (const test of tests) { + for (const browser of browsers) { + const browserType = getBrowserType(typeof browser === 'string' ? browser : browser.name); + const browserConfig = + typeof browser === 'string' ? config : deepMerge(config, browser || {}); + checkBrowserEnv(browserType); + const { devices: availableDevices, instance } = getPlaywrightInstance(browserType); + const resultDevices = getDevices(devices, availableDevices); + const key = + typeof browser === 'string' ? browser : generateKey(browser.name, browserConfig); + const browserOptions = getBrowserOptions(browserType, connectOptions); + const wsEndpoint: WsEndpointType = await this.launchServer( + browserConfig, + 'wsEndpoint' in browserOptions ? browserOptions.wsEndpoint : null, + browserType, + key, + instance as GenericBrowser + ); + + const browserTest = { + test: test as JestPlaywrightTest, + config: browserConfig, + wsEndpoint, + browser: browserType, + testTimeout: this.config.testTimeout, + }; + + if (resultDevices.length) { + resultDevices.forEach((device: DeviceType) => { + checkDevice(device, availableDevices); + if (useDefaultBrowserType) { + const deviceBrowser = getDeviceBrowserType(device!, availableDevices); + if (deviceBrowser !== null && deviceBrowser !== browser) return; + } + pwTests.push(getBrowserTest({ ...browserTest, device })); + }); + } else { + pwTests.push(getBrowserTest({ ...browserTest, device: null })); + } + } + } + + return pwTests; + } + + async runTests( + tests: Array, + watcher: TestWatcher, + options: TestRunnerOptions + ): Promise { + const { rootDir, testEnvironmentOptions } = tests[0].context.config; + const config = await readConfig( + rootDir, + testEnvironmentOptions[CONFIG_ENVIRONMENT_NAME] as JestPlaywrightConfig + ); + if (this.config.testNamePattern) { + config.launchType = LAUNCH; + config.skipInitialization = true; + config.haveSkippedTests = true; + } + const browserTests = await this.getTests(tests, config); + if (config.collectCoverage) { + await setupCoverage(); + } + + await super.runTests(browserTests, watcher, options); + + for (const key in this.browser2Server) { + await this.browser2Server[key]!.close(); + } + if (config.collectCoverage) { + await mergeCoverage(); + } + } +} + +export default PlaywrightRunner; diff --git a/src/jest-playwright-preset/constants.ts b/src/jest-playwright-preset/constants.ts new file mode 100644 index 00000000..18900dbd --- /dev/null +++ b/src/jest-playwright-preset/constants.ts @@ -0,0 +1,29 @@ +import type { JestPlaywrightConfig } from './types'; + +export const IMPORT_KIND_PLAYWRIGHT = 'playwright'; + +export const CONFIG_ENVIRONMENT_NAME = 'jest-playwright'; + +export const CHROMIUM = 'chromium'; +export const FIREFOX = 'firefox'; +export const WEBKIT = 'webkit'; + +export const LAUNCH = 'LAUNCH'; +export const PERSISTENT = 'PERSISTENT'; +export const SERVER = 'SERVER'; + +export const DEFAULT_CONFIG: JestPlaywrightConfig = { + launchType: SERVER, + launchOptions: {}, + connectOptions: {} as JestPlaywrightConfig['connectOptions'], + contextOptions: {}, + browsers: [CHROMIUM], + exitOnPageError: true, + collectCoverage: false, +}; + +export const DEFAULT_TEST_PLAYWRIGHT_TIMEOUT = 15000; +// Set timeout to 4 days +export const DEBUG_TIMEOUT = 4 * 24 * 60 * 60 * 1000; + +export const PACKAGE_NAME = '@storybook/test-runner'; diff --git a/src/jest-playwright-preset/coverage.ts b/src/jest-playwright-preset/coverage.ts new file mode 100644 index 00000000..6e180111 --- /dev/null +++ b/src/jest-playwright-preset/coverage.ts @@ -0,0 +1,55 @@ +import * as uuid from 'uuid'; +import path from 'path'; +import fs from 'fs'; +import type { Page } from 'playwright-core'; +import { promisify } from 'util'; +import rimraf from 'rimraf'; +const fsAsync = fs.promises; + +// @ts-ignore +import NYC from 'nyc'; +import { PACKAGE_NAME } from './constants'; + +const NYC_DIR = '.nyc_output'; +const COV_MERGE_DIR = path.join(NYC_DIR, 'merge'); + +const cleanMergeFiles = async (): Promise => { + await promisify(rimraf)(COV_MERGE_DIR); +}; + +export const setupCoverage = async (): Promise => { + if (!fs.existsSync(NYC_DIR)) { + await fsAsync.mkdir(NYC_DIR); + } + await cleanMergeFiles(); + await fsAsync.mkdir(COV_MERGE_DIR); +}; + +export const saveCoverageToFile = async (coverage: unknown): Promise => { + await fsAsync.writeFile(path.join(COV_MERGE_DIR, `${uuid.v4()}.json`), JSON.stringify(coverage)); +}; + +export const saveCoverageOnPage = async (page: Page, collectCoverage = false): Promise => { + if (!collectCoverage) { + console.warn( + `${PACKAGE_NAME}: saveCoverage was called but collectCoverage is not true in config file` + ); + return; + } + const coverage = await page.evaluate(`window.__coverage__`); + if (coverage) { + await saveCoverageToFile(coverage); + } +}; + +export const mergeCoverage = async (): Promise => { + const nyc = new NYC({ + _: ['merge'], + }); + const map = await nyc.getCoverageMapFromAllCoverageFiles(COV_MERGE_DIR); + const outputFile = path.join(NYC_DIR, 'coverage.json'); + const content = JSON.stringify(map, null, 2); + await fsAsync.writeFile(outputFile, content); + console.info(`Coverage file (${content.length} bytes) written to ${outputFile}`); + await cleanMergeFiles(); +}; diff --git a/src/jest-playwright-preset/global.ts b/src/jest-playwright-preset/global.ts new file mode 100644 index 00000000..26cde75b --- /dev/null +++ b/src/jest-playwright-preset/global.ts @@ -0,0 +1,57 @@ +/* eslint-disable no-console */ +import { setup as setupServer, teardown as teardownServer } from 'jest-process-manager'; +import { readConfig } from './utils'; +import type { Config as JestConfig } from '@jest/types'; +import { ServerOptions } from './types'; + +let didAlreadyRunInWatchMode = false; + +const ERROR_TIMEOUT = 'ERROR_TIMEOUT'; +const ERROR_NO_COMMAND = 'ERROR_NO_COMMAND'; + +const logMessage = ({ message, action }: { message: string; action: string }): void => { + console.log(''); + console.error(message); + console.error(`\n☝️ You ${action} in jest-playwright.config.js`); + process.exit(1); +}; + +export async function setup(jestConfig: JestConfig.GlobalConfig): Promise { + // TODO It won't work if config doesn't exist in root directory or in jest.config.js file + const config = await readConfig(jestConfig.rootDir); + + // If we are in watch mode - only setupServer() once. + if (jestConfig.watch || jestConfig.watchAll) { + if (didAlreadyRunInWatchMode) return; + didAlreadyRunInWatchMode = true; + } + + if (config.serverOptions) { + try { + await setupServer(config.serverOptions); + } catch (error) { + if (!(error instanceof Error)) throw error; + if ((error as NodeJS.ErrnoException).code === ERROR_TIMEOUT) { + logMessage({ + message: error.message, + action: 'can set "serverOptions.launchTimeout"', + }); + } + if ((error as NodeJS.ErrnoException).code === ERROR_NO_COMMAND) { + logMessage({ + message: error.message, + action: 'must set "serverOptions.command"', + }); + } + throw error; + } + } +} + +export async function teardown(jestConfig: JestConfig.GlobalConfig): Promise { + const { serverOptions } = await readConfig(jestConfig.rootDir); + + if (!jestConfig.watch && !jestConfig.watchAll) { + await teardownServer((serverOptions as ServerOptions)?.teardown); + } +} diff --git a/src/jest-playwright-preset/types.ts b/src/jest-playwright-preset/types.ts new file mode 100644 index 00000000..30166f11 --- /dev/null +++ b/src/jest-playwright-preset/types.ts @@ -0,0 +1,236 @@ +import { + Browser, + BrowserContext, + Page, + BrowserContextOptions, + LaunchOptions, + ConnectOptions, + ConnectOverCDPOptions, + BrowserType as PlaywrightBrowserType, + ViewportSize, + ChromiumBrowser, + FirefoxBrowser, + WebKitBrowser, + devices, +} from 'playwright-core'; +import { Test } from 'jest-runner'; +import { JestProcessManagerOptions } from 'jest-process-manager'; + +// TODO Find out flex ways to reuse constants +declare const IMPORT_KIND_PLAYWRIGHT = 'playwright'; + +declare const CONFIG_ENVIRONMENT_NAME = 'jest-playwright'; + +declare const CHROMIUM = 'chromium'; +declare const FIREFOX = 'firefox'; +declare const WEBKIT = 'webkit'; + +declare const LAUNCH = 'LAUNCH'; +declare const PERSISTENT = 'PERSISTENT'; +declare const SERVER = 'SERVER'; + +export type BrowserType = typeof CHROMIUM | typeof FIREFOX | typeof WEBKIT; + +export type SkipOption = { + browsers: BrowserType[]; + devices?: string[] | RegExp; +}; + +type Global = typeof globalThis; + +export interface JestPlaywrightGlobal extends Global { + [CONFIG_ENVIRONMENT_NAME]: JestPlaywrightConfig; +} + +export interface TestPlaywrightConfigOptions extends JestPlaywrightConfig { + browser?: BrowserType; + device?: ConfigDeviceType; +} + +export type GenericBrowser = PlaywrightBrowserType< + WebKitBrowser | ChromiumBrowser | FirefoxBrowser +>; + +export type Nullable = T | null; + +interface JestPlaywright { + /** + * Reset global.page + * + * ```ts + * it('should reset page', async () => { + * await jestPlaywright.resetPage() + * }) + * ``` + */ + resetPage: () => Promise; + /** + * Reset global.context + * + * ```ts + * it('should reset context', async () => { + * await jestPlaywright.resetContext() + * }) + * ``` + */ + resetContext: (newOptions?: BrowserContextOptions) => Promise; + /** + * Reset global.browser, global.context, and global.page + * + * ```ts + * it('should reset page', async () => { + * await jestPlaywright.resetBrowser() + * }) + * ``` + */ + resetBrowser: (newOptions?: BrowserContextOptions) => Promise; + /** + * Saves the coverage to the disk which will only work if `collectCoverage` + * in `jest-playwright.config.js` file is set to true. The merged coverage file + * is then available in `.nyc_output/coverage.json`. Mostly its needed in the + * `afterEach` handler like that: + * + * ```ts + * afterEach(async () => { + * await jestPlaywright.saveCoverage(page) + * }) + * ``` + */ + saveCoverage: (page: Page) => Promise; + configSeparateEnv: ( + config: Partial, + isDebug?: boolean + ) => Promise; +} + +interface JestParams { + (options: T, name: string, fn?: jest.ProvidesCallback, timeout?: number): void; +} + +type ProvidesCallback = (cb: ConfigParams) => void; + +interface JestParamsWithConfigParams { + (options: Partial, name: string, fn?: ProvidesCallback, timeout?: number): void; +} + +interface JestPlaywrightTestDebug extends JestParamsWithConfigParams { + (name: string, fn?: ProvidesCallback, timeout?: number): void; + skip: JestParamsWithConfigParams | JestPlaywrightTestDebug; + only: JestParamsWithConfigParams | JestPlaywrightTestDebug; +} + +interface JestPlaywrightTestConfig extends JestParamsWithConfigParams { + skip: JestParamsWithConfigParams | JestPlaywrightTestConfig; + only: JestParamsWithConfigParams | JestPlaywrightTestConfig; +} + +declare global { + const browserName: BrowserType; + const deviceName: Nullable; + const page: Page; + const browser: Browser; + const context: BrowserContext; + const jestPlaywright: JestPlaywright; + namespace jest { + interface It { + jestPlaywrightSkip: JestParams; + jestPlaywrightDebug: JestPlaywrightTestDebug; + jestPlaywrightConfig: JestPlaywrightTestConfig; + } + interface Describe { + jestPlaywrightSkip: JestParams; + } + } +} + +type DeviceDescriptor = { + viewport: Nullable; + userAgent: string; + deviceScaleFactor: number; + isMobile: boolean; + hasTouch: boolean; + defaultBrowserType: BrowserType; +}; + +export type CustomDeviceType = Partial & { + name: string; +}; + +export type ConfigDeviceType = CustomDeviceType | string; + +export type DeviceType = Nullable; + +export type WsEndpointType = Nullable; + +export type SelectorType = { + script: string | Function | { path?: string; content?: string }; + name: string; +}; + +export type PlaywrightRequireType = BrowserType | typeof IMPORT_KIND_PLAYWRIGHT; + +export interface Playwright { + name: PlaywrightRequireType; + instance: GenericBrowser | Record; + devices: typeof devices; +} + +type LaunchType = typeof LAUNCH | typeof SERVER | typeof PERSISTENT; + +export type Options = T & Partial>; + +export type ServerOptions = JestProcessManagerOptions & { + teardown?: string; +}; + +export interface JestPlaywrightConfig { + haveSkippedTests?: boolean; + skipInitialization?: boolean; + resetContextPerTest?: boolean; + testTimeout?: number; + debugOptions?: JestPlaywrightConfig; + launchType?: LaunchType; + launchOptions?: Options; + connectOptions?: Options<(ConnectOptions & { wsEndpoint: string }) | ConnectOverCDPOptions>; + contextOptions?: Options; + userDataDir?: string; + exitOnPageError?: boolean; + displayName?: string; + browsers: (BrowserType | (JestPlaywrightConfig & { name: BrowserType }))[]; + devices?: ConfigDeviceType[] | RegExp; + useDefaultBrowserType?: boolean; + serverOptions?: ServerOptions | ServerOptions[]; + selectors?: SelectorType[]; + collectCoverage?: boolean; +} + +export type JestPlaywrightProjectConfig = Test['context']['config'] & { + browserName: BrowserType; + wsEndpoint: WsEndpointType; + device: DeviceType; +}; + +export type JestPlaywrightContext = Omit & { + config: JestPlaywrightProjectConfig; +}; + +export type JestPlaywrightTest = Omit & { + context: JestPlaywrightContext; +}; + +export interface BrowserTest { + test: JestPlaywrightTest; + config: JestPlaywrightConfig; + browser: BrowserType; + wsEndpoint: WsEndpointType; + device: DeviceType; + testTimeout?: number; +} + +export type ConfigParams = { + browserName: BrowserType; + deviceName: Nullable; + browser: Nullable; + context: BrowserContext; + page: Page; +}; diff --git a/src/jest-playwright-preset/utils.ts b/src/jest-playwright-preset/utils.ts new file mode 100644 index 00000000..8abab53f --- /dev/null +++ b/src/jest-playwright-preset/utils.ts @@ -0,0 +1,209 @@ +import fs from 'fs'; +import path from 'path'; +import type { + BrowserType, + ConfigDeviceType, + DeviceType, + JestPlaywrightConfig, + Playwright, + PlaywrightRequireType, + SkipOption, + Options, + Nullable, +} from './types'; +import { + CHROMIUM, + DEFAULT_CONFIG, + FIREFOX, + IMPORT_KIND_PLAYWRIGHT, + WEBKIT, + PACKAGE_NAME, + CONFIG_ENVIRONMENT_NAME, +} from './constants'; + +const fsPromises = fs.promises; +const BROWSERS = [CHROMIUM, FIREFOX, WEBKIT]; + +class PlaywrightError extends Error { + constructor(message: string) { + super(formatError(message)); + this.name = 'PlaywrightError'; + } +} + +export const checkBrowserEnv = (param: BrowserType): void => { + if (!BROWSERS.includes(param)) { + throw new PlaywrightError( + `Wrong browser type. Should be one of [${BROWSERS.join(', ')}], but got ${param}` + ); + } +}; + +/* eslint-disable @typescript-eslint/no-explicit-any*/ +const isObject = (item: any) => { + return item && typeof item === 'object' && !Array.isArray(item); +}; + +export const deepMerge = >(target: T, source: T): T => { + let output = { ...target }; + const keys: (keyof T)[] = Object.keys(source); + if (isObject(target) && isObject(source)) { + keys.forEach((key) => { + if (Array.isArray(source[key]) && Array.isArray(target[key])) { + output = { ...output, [key]: [...source[key], ...target[key]] }; + } else if (isObject(source[key])) { + if (!(key in target)) { + output = { ...output, [key]: source[key] }; + } else { + output[key] = deepMerge(target[key], source[key]); + } + } else { + output = { ...output, [key]: source[key] }; + } + }); + } + return output; +}; + +export const checkDeviceEnv = (device: string, availableDevices: string[]): void => { + if (!availableDevices.includes(device)) { + throw new PlaywrightError( + `Wrong device. Should be one of [${availableDevices}], but got ${device}` + ); + } +}; + +export const checkDevice = (device: DeviceType, availableDevices: Playwright['devices']): void => { + if (typeof device === 'string') { + const availableDeviceNames = Object.keys(availableDevices); + checkDeviceEnv(device, availableDeviceNames); + } +}; + +export const getDisplayName = (browser: string, device: DeviceType): string => { + const result = `browser: ${browser}`; + if (device !== null) { + if (typeof device === 'string') { + return `${result} device: ${device}`; + } + if (device.name) { + return `${result} device: ${device.name}`; + } + } + return result; +}; + +export const getBrowserType = (browser?: BrowserType): BrowserType => { + return browser || CHROMIUM; +}; + +export const generateKey = (browser: BrowserType, config: JestPlaywrightConfig): string => + `${browser}${JSON.stringify(config)}`; + +export const getDeviceBrowserType = ( + device: ConfigDeviceType, + availableDevices: Playwright['devices'] +): Nullable => { + if (typeof device === 'string') { + return availableDevices[device].defaultBrowserType as BrowserType; + } + + return device?.defaultBrowserType || null; +}; + +export const getPlaywrightInstance = (browserName?: BrowserType): Playwright => { + let pw; + let name: PlaywrightRequireType; + if (!browserName) { + pw = require(IMPORT_KIND_PLAYWRIGHT); + name = IMPORT_KIND_PLAYWRIGHT; + return { + name, + instance: pw, + devices: pw['devices'], + }; + } + try { + pw = require(`${IMPORT_KIND_PLAYWRIGHT}-${browserName}`); + name = browserName; + } catch (e) { + try { + pw = require(IMPORT_KIND_PLAYWRIGHT); + name = IMPORT_KIND_PLAYWRIGHT; + } catch (e) { + throw new PlaywrightError(`Cannot find playwright package to use ${browserName}`); + } + } + if (!pw[browserName]) { + throw new PlaywrightError(`Cannot find playwright package to use ${browserName}`); + } + return { + name, + instance: pw[browserName], + devices: pw['devices'], + }; +}; + +export function getBrowserOptions>( + browserName: BrowserType, + options?: Options +): T { + let result: Options = options ? { ...options } : ({} as Options); + if (result[browserName]) { + result = deepMerge(result, result[browserName]!); + } + BROWSERS.forEach((browser) => { + delete result![browser as BrowserType]; + }); + return result as T; +} + +export const getSkipFlag = ( + skipOptions: SkipOption, + browserName: BrowserType, + deviceName: Nullable +): boolean => { + const { browsers, devices } = skipOptions; + const isBrowserIncluded = browsers.includes(browserName); + if (!devices) { + return isBrowserIncluded; + } else { + if (devices instanceof RegExp) { + return isBrowserIncluded && devices.test(deviceName!); + } + return isBrowserIncluded && devices.includes(deviceName!); + } +}; + +export const readConfig = async ( + rootDir = process.cwd(), + jestEnvConfig?: JestPlaywrightConfig +): Promise => { + if (jestEnvConfig) { + return { ...DEFAULT_CONFIG, ...jestEnvConfig }; + } + const { JEST_PLAYWRIGHT_CONFIG, npm_package_type } = process.env; + const fileExtension = npm_package_type === 'module' ? 'cjs' : 'js'; + const configPath = JEST_PLAYWRIGHT_CONFIG || `${CONFIG_ENVIRONMENT_NAME}.config.${fileExtension}`; + const absConfigPath = path.resolve(rootDir, configPath); + try { + await fsPromises.access(absConfigPath); + } catch (e) { + if (JEST_PLAYWRIGHT_CONFIG) { + throw new PlaywrightError( + `Can't find a root directory while resolving a config file path.\nProvided path to resolve: ${configPath}` + ); + } else { + return DEFAULT_CONFIG; + } + } + + const localConfig = await require(absConfigPath); + if (typeof localConfig === 'function') { + const config = await localConfig(); + return { ...DEFAULT_CONFIG, ...config }; + } + return { ...DEFAULT_CONFIG, ...localConfig }; +}; + +export const formatError = (error: string): string => `${PACKAGE_NAME}: ${error}`; diff --git a/src/playwright/hooks.test.ts b/src/playwright/hooks.test.ts index 673f445e..65923056 100644 --- a/src/playwright/hooks.test.ts +++ b/src/playwright/hooks.test.ts @@ -6,13 +6,14 @@ import { TestRunnerConfig, waitForPageReady, } from './hooks'; +import { describe, it, expect, beforeEach, Mock, vi } from 'vitest'; -type MockPage = Page & { evaluate: jest.Mock }; +type MockPage = Page & { evaluate: Mock }; describe('test-runner', () => { describe('setPreVisit', () => { it('sets the preVisit function', () => { - const preVisit = jest.fn(); + const preVisit = vi.fn(); setPreVisit(preVisit); expect(globalThis.__sbPreVisit).toBe(preVisit); }); @@ -20,7 +21,7 @@ describe('test-runner', () => { describe('setPostVisit', () => { it('sets the postVisit function', () => { - const postVisit = jest.fn(); + const postVisit = vi.fn(); setPostVisit(postVisit); expect(globalThis.__sbPostVisit).toBe(postVisit); }); @@ -28,11 +29,11 @@ describe('test-runner', () => { describe('getStoryContext', () => { const page = { - evaluate: jest.fn(), + evaluate: vi.fn(), } as MockPage; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('calls page.evaluate with the correct arguments', async () => { @@ -54,7 +55,7 @@ describe('test-runner', () => { const storyContext = { kind: 'kind', name: 'name' }; // Mock globalThis.__getContext - globalThis.__getContext = jest.fn(); + globalThis.__getContext = vi.fn(); page.evaluate.mockImplementation(async (func) => { // Call the function passed to page.evaluate @@ -81,8 +82,8 @@ describe('test-runner', () => { beforeEach(() => { page = { - waitForLoadState: jest.fn(), - evaluate: jest.fn(), + waitForLoadState: vi.fn(), + evaluate: vi.fn(), } as unknown as Page; }); @@ -96,8 +97,8 @@ describe('test-runner', () => { it('calls page.evaluate with () => document.fonts.ready', async () => { const page = { - waitForLoadState: jest.fn(), - evaluate: jest.fn(), + waitForLoadState: vi.fn(), + evaluate: vi.fn(), } as unknown as MockPage; // Mock document.fonts.ready diff --git a/src/playwright/index.ts b/src/playwright/index.ts index e24e8811..53a364ee 100644 --- a/src/playwright/index.ts +++ b/src/playwright/index.ts @@ -1,14 +1,19 @@ -import { transformSync as swcTransform } from '@swc/core'; +import { transform as swcTransform } from '@swc/core'; import { transformPlaywright } from './transformPlaywright'; -export const process = (src: string, filename: string) => { - const csfTest = transformPlaywright(src, filename); +export const processAsync = async (src: string, filename: string) => { + const csfTest = await transformPlaywright(src, filename); - const result = swcTransform(csfTest, { + const result = await swcTransform(csfTest, { filename, + isModule: true, module: { - type: 'commonjs', + type: 'es6', }, }); return result ? result.code : src; }; + +export const process = (src: string) => { + return src; +}; diff --git a/src/playwright/transformPlaywright.test.ts b/src/playwright/transformPlaywright.test.ts index 09006641..feaf007e 100644 --- a/src/playwright/transformPlaywright.test.ts +++ b/src/playwright/transformPlaywright.test.ts @@ -1,13 +1,19 @@ import dedent from 'ts-dedent'; import path from 'path'; import * as storybookMain from '../util/getStorybookMain'; +import { describe, beforeEach, expect, it, vi } from 'vitest'; import { transformPlaywright } from './transformPlaywright'; -jest.mock('storybook/internal/common', () => ({ - ...jest.requireActual('storybook/internal/common'), - getProjectRoot: jest.fn(() => '/foo/bar'), - normalizeStories: jest.fn(() => [ +vi.mock('storybook/internal/preview-api', async (importOriginal) => ({ + ...(await importOriginal()), + userOrAutoTitle: vi.fn(() => 'Example/Header'), +})); + +vi.mock('storybook/internal/common', async (importOriginal) => ({ + ...(await importOriginal()), + getProjectRoot: vi.fn(() => '/stories/basic'), + normalizeStories: vi.fn(() => [ { titlePrefix: 'Example', files: '**/*.stories.@(mdx|tsx|ts|jsx|js)', @@ -18,7 +24,7 @@ jest.mock('storybook/internal/common', () => ({ ]), })); -jest.mock('../util/getTestRunnerConfig'); +vi.mock('../util/getTestRunnerConfig'); expect.addSnapshotSerializer({ print: (val: unknown) => (typeof val === 'string' ? val.trim() : String(val)), @@ -28,16 +34,18 @@ expect.addSnapshotSerializer({ describe('Playwright', () => { const filename = './stories/basic/Header.stories.js'; beforeEach(() => { - const relativeSpy = jest.spyOn(path, 'relative'); + const relativeSpy = vi.spyOn(path, 'relative'); relativeSpy.mockReturnValueOnce('stories/basic/Header.stories.js'); - jest.spyOn(storybookMain, 'getStorybookMain').mockImplementation(() => ({ - stories: [ - { - directory: '../stories/basic', - titlePrefix: 'Example', - }, - ], - })); + vi.spyOn(storybookMain, 'getStorybookMain').mockImplementation(() => + Promise.resolve({ + stories: [ + { + directory: '../stories/basic', + titlePrefix: 'Example', + }, + ], + }) + ); delete process.env.STORYBOOK_INCLUDE_TAGS; delete process.env.STORYBOOK_EXCLUDE_TAGS; @@ -46,8 +54,8 @@ describe('Playwright', () => { }); describe('tag filtering mechanism', () => { - it('should include all stories when there is no tag filtering', () => { - expect( + it('should include all stories when there is no tag filtering', async () => { + await expect( transformPlaywright( dedent` export default { title: 'foo/bar', component: Button }; @@ -56,136 +64,134 @@ describe('Playwright', () => { `, filename ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { - describe("A", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--a", - title: "Example/foo/bar", - name: "A" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--a" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { + describe("A", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--a", + title: "Example/Header", + name: "A" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--a" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); - }); - describe("B", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--b", - title: "Example/foo/bar", - name: "B" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--b" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); } - return result; + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"A"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } + }); + }); + describe("B", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--b", + title: "Example/Header", + name: "B" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--b" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"B"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"B"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } }); }); - } + }); `); }); - it('should exclude stories when excludeTags matches', () => { + it('should exclude stories when excludeTags matches', async () => { process.env.STORYBOOK_EXCLUDE_TAGS = 'exclude-test'; - expect( + await expect( transformPlaywright( dedent` export default { title: 'foo/bar', component: Button }; @@ -194,76 +200,74 @@ describe('Playwright', () => { `, filename ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { - describe("B", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--b", - title: "Example/foo/bar", - name: "B" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--b" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { + describe("B", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--b", + title: "Example/Header", + name: "B" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--b" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"B"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"B"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } }); }); - } + }); `); }); - it('should skip stories when skipTags matches', () => { + it('should skip stories when skipTags matches', async () => { process.env.STORYBOOK_SKIP_TAGS = 'skip-test'; - expect( + await expect( transformPlaywright( dedent` export default { title: 'foo/bar', component: Button }; @@ -272,134 +276,132 @@ describe('Playwright', () => { `, filename ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { - describe("A", () => { - it.skip("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--a", - title: "Example/foo/bar", - name: "A" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--a" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { + describe("A", () => { + it.skip("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--a", + title: "Example/Header", + name: "A" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--a" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); - }); - describe("B", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--b", - title: "Example/foo/bar", - name: "B" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--b" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); } - return result; + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"A"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } + }); + }); + describe("B", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--b", + title: "Example/Header", + name: "B" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--b" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"B"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"B"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } }); }); - } + }); `); }); - it('should work in conjunction with includeTags, excludeTags and skipTags', () => { + it('should work in conjunction with includeTags, excludeTags and skipTags', async () => { process.env.STORYBOOK_INCLUDE_TAGS = 'play,design,global-tag'; process.env.STORYBOOK_SKIP_TAGS = 'skip'; process.env.STORYBOOK_EXCLUDE_TAGS = 'exclude'; @@ -411,7 +413,7 @@ describe('Playwright', () => { // - C being included // - D being included // - E being excluded - expect( + await expect( transformPlaywright( dedent` export default { title: 'foo/bar', component: Button }; @@ -423,511 +425,276 @@ describe('Playwright', () => { `, filename ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { - describe("B", () => { - it.skip("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--b", - title: "Example/foo/bar", - name: "B" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--b" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { + describe("B", () => { + it.skip("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--b", + title: "Example/Header", + name: "B" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--b" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"B"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); - }); - describe("C", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--c", - title: "Example/foo/bar", - name: "C" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--c" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); } - return result; - }; - try { + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"B"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); await testFn(); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"C"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); - } else { - throw err; - } + } else { + throw err; } - }); + } }); - describe("D", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--d", - title: "Example/foo/bar", - name: "D" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--d" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; + }); + describe("C", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--c", + title: "Example/Header", + name: "C" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--c" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"D"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); - }); - describe("E", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--e", - title: "Example/foo/bar", - name: "E" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--e" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); } - return result; + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"C"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } + }); + }); + describe("D", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--d", + title: "Example/Header", + name: "D" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--d" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"E"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); - }); - }); - } - `); - }); - it('should work with tag negation', () => { - process.env.STORYBOOK_INCLUDE_TAGS = 'play,test'; - process.env.STORYBOOK_PREVIEW_TAGS = '!test'; - // Should result in: - // - A being included - // - B being excluded because it has no play nor test tag (removed by negation in preview tags) - // - C being included because it has test tag (overwritten via story tags) - expect( - transformPlaywright( - dedent` - export default { title: 'foo/bar', component: Button, tags: ['play'] }; - export const A = { }; - export const B = { tags: ['!play'] }; - export const C = { tags: ['!play', 'test'] }; - `, - filename - ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { - describe("A", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--a", - title: "Example/foo/bar", - name: "A" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--a" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); } - return result; - }; - try { + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"D"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); await testFn(); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); - } else { - throw err; - } + } else { + throw err; } - }); + } }); - describe("C", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--c", - title: "Example/foo/bar", - name: "C" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--c" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); - } - return result; + }); + describe("E", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--e", + title: "Example/Header", + name: "E" }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; try { - await testFn(); + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--e" + }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"C"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); + throw err; } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } throw err; } } - }); - }); - }); - } - `); - }); - it('should include "test" tag by default', () => { - // Should result in: - // - A being included - // - B being excluded - expect( - transformPlaywright( - dedent` - export default { title: 'foo/bar', component: Button }; - export const A = { }; - export const B = { tags: ['!test'] }; - `, - filename - ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { - describe("A", () => { - it("smoke-test", async () => { - const testFn = async () => { - const context = { - id: "example-foo-bar--a", - title: "Example/foo/bar", - name: "A" - }; - if (globalThis.__sbPreVisit) { - await globalThis.__sbPreVisit(page, context); - } - let result; - try { - result = await page.evaluate(({ - id, - hasPlayFn - }) => __test(id, hasPlayFn), { - id: "example-foo-bar--a" - }); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - throw err; - } else { - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, { - ...context, - hasFailure: true - }); - } - throw err; - } - } - if (globalThis.__sbPostVisit) { - await globalThis.__sbPostVisit(page, context); - } - if (globalThis.__sbCollectCoverage) { - const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); - if (!isCoverageSetupCorrectly) { - throw new Error(\`[Test runner] An error occurred when evaluating code coverage: - The code in this story is not instrumented, which means the coverage setup is likely not correct. - More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); - } - await jestPlaywright.saveCoverage(page); + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); } - return result; - }; - try { + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"E"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); await testFn(); - } catch (err) { - if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`); - await jestPlaywright.resetPage(); - await globalThis.__sbSetupPage(globalThis.page, globalThis.context); - await testFn(); - } else { - throw err; - } + } else { + throw err; } - }); + } }); }); - } + }); `); }); - it('should no op when includeTags is passed but not matched', () => { - process.env.STORYBOOK_INCLUDE_TAGS = 'play'; - expect( + it('should work with tag negation', async () => { + process.env.STORYBOOK_INCLUDE_TAGS = 'play,test'; + process.env.STORYBOOK_PREVIEW_TAGS = '!test'; + // Should result in: + // - A being included + // - B being excluded because it has no play nor test tag (removed by negation in preview tags) + // - C being included because it has test tag (overwritten via story tags) + await expect( transformPlaywright( dedent` - export default { title: 'foo/bar', component: Button }; - export const A = () => {}; - A.play = () => {}; + export default { title: 'foo/bar', component: Button, tags: ['play'] }; + export const A = { }; + export const B = { tags: ['!play'] }; + export const C = { tags: ['!play', 'test'] }; `, filename ) - ).toMatchInlineSnapshot(`describe.skip('Example/foo/bar', () => { it('no-op', () => {}) });`); - }); - }); - - it('should generate a play test when the story has a play function', () => { - expect( - transformPlaywright( - dedent` - export default { title: 'foo/bar', component: Button }; - export const A = () => {}; - A.play = () => {}; - `, - filename - ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { describe("A", () => { - it("play-test", async () => { + it("smoke-test", async () => { const testFn = async () => { const context = { - id: "example-foo-bar--a", - title: "Example/foo/bar", + id: "example-header--a", + title: "Example/Header", name: "A" }; if (globalThis.__sbPreVisit) { @@ -939,7 +706,7 @@ describe('Playwright', () => { id, hasPlayFn }) => __test(id, hasPlayFn), { - id: "example-foo-bar--a" + id: "example-header--a" }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { @@ -972,7 +739,7 @@ describe('Playwright', () => { await testFn(); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`); + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"A"}". Retrying...\`); await jestPlaywright.resetPage(); await globalThis.__sbSetupPage(globalThis.page, globalThis.context); await testFn(); @@ -982,29 +749,13 @@ describe('Playwright', () => { } }); }); - }); - } - `); - }); - it('should generate a smoke test when story does not have a play function', () => { - expect( - transformPlaywright( - dedent` - export default { title: 'foo/bar' }; - export const A = () => {}; - `, - filename - ) - ).toMatchInlineSnapshot(` - if (!require.main) { - describe("Example/foo/bar", () => { - describe("A", () => { + describe("C", () => { it("smoke-test", async () => { const testFn = async () => { const context = { - id: "example-foo-bar--a", - title: "Example/foo/bar", - name: "A" + id: "example-header--c", + title: "Example/Header", + name: "C" }; if (globalThis.__sbPreVisit) { await globalThis.__sbPreVisit(page, context); @@ -1015,7 +766,7 @@ describe('Playwright', () => { id, hasPlayFn }) => __test(id, hasPlayFn), { - id: "example-foo-bar--a" + id: "example-header--c" }); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { @@ -1048,7 +799,7 @@ describe('Playwright', () => { await testFn(); } catch (err) { if (err.toString().includes('Execution context was destroyed')) { - console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`); + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"C"}". Retrying...\`); await jestPlaywright.resetPage(); await globalThis.__sbSetupPage(globalThis.page, globalThis.context); await testFn(); @@ -1059,20 +810,22 @@ describe('Playwright', () => { }); }); }); - } - `); - }); - it('should generate a smoke test with auto title', () => { - expect( - transformPlaywright( - dedent` - export default { component: Button }; - export const A = () => {}; + `); + }); + it('should include "test" tag by default', async () => { + // Should result in: + // - A being included + // - B being excluded + await expect( + transformPlaywright( + dedent` + export default { title: 'foo/bar', component: Button }; + export const A = { }; + export const B = { tags: ['!test'] }; `, - filename - ) - ).toMatchInlineSnapshot(` - if (!require.main) { + filename + ) + ).resolves.toMatchInlineSnapshot(` describe("Example/Header", () => { describe("A", () => { it("smoke-test", async () => { @@ -1135,7 +888,246 @@ describe('Playwright', () => { }); }); }); - } + `); + }); + it('should no op when includeTags is passed but not matched', async () => { + process.env.STORYBOOK_INCLUDE_TAGS = 'play'; + await expect( + transformPlaywright( + dedent` + export default { title: 'foo/bar', component: Button }; + export const A = () => {}; + A.play = () => {}; + `, + filename + ) + ).resolves.toMatchInlineSnapshot( + `describe.skip('Example/Header', () => { it('no-op', () => {}) });` + ); + }); + }); + + it('should generate a play test when the story has a play function', async () => { + await expect( + transformPlaywright( + dedent` + export default { title: 'foo/bar', component: Button }; + export const A = () => {}; + A.play = () => {}; + `, + filename + ) + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { + describe("A", () => { + it("play-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--a", + title: "Example/Header", + name: "A" + }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; + try { + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--a" + }); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + throw err; + } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } + throw err; + } + } + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"A"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } + }); + }); + }); + `); + }); + it('should generate a smoke test when story does not have a play function', async () => { + await expect( + transformPlaywright( + dedent` + export default { title: 'foo/bar' }; + export const A = () => {}; + `, + filename + ) + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { + describe("A", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--a", + title: "Example/Header", + name: "A" + }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; + try { + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--a" + }); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + throw err; + } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } + throw err; + } + } + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"A"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } + }); + }); + }); + `); + }); + it('should generate a smoke test with auto title', async () => { + await expect( + transformPlaywright( + dedent` + export default { component: Button }; + export const A = () => {}; + `, + filename + ) + ).resolves.toMatchInlineSnapshot(` + describe("Example/Header", () => { + describe("A", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "example-header--a", + title: "Example/Header", + name: "A" + }; + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + let result; + try { + result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "example-header--a" + }); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + throw err; + } else { + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, { + ...context, + hasFailure: true + }); + } + throw err; + } + } + if (globalThis.__sbPostVisit) { + await globalThis.__sbPostVisit(page, context); + } + if (globalThis.__sbCollectCoverage) { + const isCoverageSetupCorrectly = await page.evaluate(() => '__coverage__' in window); + if (!isCoverageSetupCorrectly) { + throw new Error(\`[Test runner] An error occurred when evaluating code coverage: + The code in this story is not instrumented, which means the coverage setup is likely not correct. + More info: https://github.com/storybookjs/test-runner#setting-up-code-coverage\`); + } + await jestPlaywright.saveCoverage(page); + } + return result; + }; + try { + await testFn(); + } catch (err) { + if (err.toString().includes('Execution context was destroyed')) { + console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"A"}". Retrying...\`); + await jestPlaywright.resetPage(); + await globalThis.__sbSetupPage(globalThis.page, globalThis.context); + await testFn(); + } else { + throw err; + } + } + }); + }); + }); `); }); }); diff --git a/src/playwright/transformPlaywright.ts b/src/playwright/transformPlaywright.ts index 818296c3..f15a3a76 100644 --- a/src/playwright/transformPlaywright.ts +++ b/src/playwright/transformPlaywright.ts @@ -1,9 +1,12 @@ import { relative } from 'path'; -import template from '@babel/template'; +import babelTemplate from '@babel/template'; + +// Handle both ESM and CJS patterns +const template = (babelTemplate as any).default ?? babelTemplate; import { userOrAutoTitle } from 'storybook/internal/preview-api'; import dedent from 'ts-dedent'; -import { getStorybookMetadata } from '../util'; +import { getStorybookMetadata } from '../util/getStorybookMetadata'; import { transformCsf } from '../csf/transformCsf'; import type { TestPrefixer } from '../csf/transformCsf'; @@ -79,24 +82,24 @@ export const testPrefixer: TestPrefixer = (context) => { )({ ...context }); }; -const makeTitleFactory = (filename: string) => { - const { workingDir, normalizedStoriesEntries } = getStorybookMetadata(); +const makeTitleFactory = async (filename: string) => { + const { workingDir, normalizedStoriesEntries } = await getStorybookMetadata(); const filePath = `./${relative(workingDir, filename)}`; return (userTitle: string) => userOrAutoTitle(filePath, normalizedStoriesEntries, userTitle) as string; }; -export const transformPlaywright = (src: string, filename: string) => { +export const transformPlaywright = async (src: string, filename: string) => { const tags = process.env.STORYBOOK_PREVIEW_TAGS?.split(',') ?? []; const transformOptions = { testPrefixer, insertTestIfEmpty: true, clearBody: true, - makeTitle: makeTitleFactory(filename), + makeTitle: await makeTitleFactory(filename), previewAnnotations: { tags }, }; - const result = transformCsf(src, transformOptions); + const result = await transformCsf(src, transformOptions); return result; }; diff --git a/src/playwright/transformPlaywrightJson.test.ts b/src/playwright/transformPlaywrightJson.test.ts index 0a1b398d..8a4d47d5 100644 --- a/src/playwright/transformPlaywrightJson.test.ts +++ b/src/playwright/transformPlaywrightJson.test.ts @@ -5,9 +5,10 @@ import { makeDescribe, transformPlaywrightJson, } from './transformPlaywrightJson'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as t from '@babel/types'; -jest.mock('../util/getTestRunnerConfig'); +vi.mock('../util/getTestRunnerConfig'); describe('Playwright Json', () => { describe('v4 indexes', () => { @@ -17,7 +18,7 @@ describe('Playwright Json', () => { delete process.env.STORYBOOK_SKIP_TAGS; }); - it('should generate a test for each story', () => { + it('should generate a test for each story', async () => { const input = { v: 4, entries: { @@ -41,7 +42,7 @@ describe('Playwright Json', () => { }, }, } satisfies V4Index; - expect(transformPlaywrightJson(input)).toMatchInlineSnapshot(` + await expect(transformPlaywrightJson(input)).resolves.toMatchInlineSnapshot(` { "example-header": "describe("Example/Header", () => { describe("Logged In", () => { @@ -231,7 +232,7 @@ describe('Playwright Json', () => { `); }); - it('should respect include, exclude and skip tags', () => { + it('should respect include, exclude and skip tags', async () => { process.env.STORYBOOK_INCLUDE_TAGS = 'play,design'; process.env.STORYBOOK_SKIP_TAGS = 'skip'; process.env.STORYBOOK_EXCLUDE_TAGS = 'exclude'; @@ -272,7 +273,7 @@ describe('Playwright Json', () => { // - B being included, but skipped // - C being included // - D being excluded - expect(transformPlaywrightJson(input)).toMatchInlineSnapshot(` + await expect(transformPlaywrightJson(input)).resolves.toMatchInlineSnapshot(` { "example-header": "describe("Example/Header", () => { describe("Logged Out", () => { @@ -402,7 +403,7 @@ describe('Playwright Json', () => { `); }); - it('should skip docs entries', () => { + it('should skip docs entries', async () => { const input = { v: 4, entries: { @@ -419,7 +420,7 @@ describe('Playwright Json', () => { }, }, } satisfies V4Index; - expect(transformPlaywrightJson(input)).toMatchInlineSnapshot(` + await expect(transformPlaywrightJson(input)).resolves.toMatchInlineSnapshot(` { "example-page": "describe("Example/Page", () => { describe("Logged In", () => { @@ -489,7 +490,7 @@ describe('Playwright Json', () => { }); describe('v3 indexes', () => { - it('should generate a test for each story', () => { + it('should generate a test for each story', async () => { const input = { v: 3, stories: { @@ -525,7 +526,7 @@ describe('Playwright Json', () => { }, }, } satisfies V3StoriesIndex; - expect(transformPlaywrightJson(input)).toMatchInlineSnapshot(` + await expect(transformPlaywrightJson(input)).resolves.toMatchInlineSnapshot(` { "example-header": "describe("Example/Header", () => { describe("Logged In", () => { @@ -715,7 +716,7 @@ describe('Playwright Json', () => { `); }); - it('should skip docs-only stories', () => { + it('should skip docs-only stories', async () => { const input = { v: 3, stories: { @@ -741,7 +742,7 @@ describe('Playwright Json', () => { }, }, } satisfies V3StoriesIndex; - expect(transformPlaywrightJson(input)).toMatchInlineSnapshot(` + await expect(transformPlaywrightJson(input)).resolves.toMatchInlineSnapshot(` { "example-page": "describe("Example/Page", () => { describe("Logged In", () => { @@ -809,7 +810,7 @@ describe('Playwright Json', () => { `); }); - it('should include "test" tag by default', () => { + it('should include "test" tag by default', async () => { process.env.STORYBOOK_INCLUDE_TAGS = 'test'; const input = { v: 3, @@ -826,7 +827,7 @@ describe('Playwright Json', () => { }, }, } satisfies V3StoriesIndex; - expect(transformPlaywrightJson(input)).toMatchInlineSnapshot(` + await expect(transformPlaywrightJson(input)).resolves.toMatchInlineSnapshot(` { "example-page": "describe("Example/Page", () => { describe("Logged In", () => { @@ -897,9 +898,9 @@ describe('Playwright Json', () => { }); describe('unsupported index', () => { - it('throws an error for unsupported versions', () => { + it('throws an error for unsupported versions', async () => { const unsupportedVersion = { v: 1 } satisfies UnsupportedVersion; - expect(() => transformPlaywrightJson(unsupportedVersion)).toThrowError( + await expect(transformPlaywrightJson(unsupportedVersion)).rejects.toThrowError( `Unsupported version ${unsupportedVersion.v}` ); }); diff --git a/src/playwright/transformPlaywrightJson.ts b/src/playwright/transformPlaywrightJson.ts index 9a8b1238..4c2f4a0a 100644 --- a/src/playwright/transformPlaywrightJson.ts +++ b/src/playwright/transformPlaywrightJson.ts @@ -1,5 +1,8 @@ import * as t from '@babel/types'; -import generate from '@babel/generator'; +import babelGenerate from '@babel/generator'; + +// Handle both ESM and CJS patterns +const generate = (babelGenerate as any).default ?? babelGenerate; import { ComponentTitle, StoryId, StoryName, toId } from 'storybook/internal/csf'; import { testPrefixer } from './transformPlaywright'; @@ -140,7 +143,9 @@ function groupByTitleId(entries: T[]) { * Generate one test file per component so that Jest can * run them in parallel. */ -export const transformPlaywrightJson = (index: V3StoriesIndex | V4Index | UnsupportedVersion) => { +export const transformPlaywrightJson = async ( + index: V3StoriesIndex | V4Index | UnsupportedVersion +) => { let titleIdToEntries: Record; if (index.v === 3) { const titleIdToStories = groupByTitleId( @@ -157,7 +162,7 @@ export const transformPlaywrightJson = (index: V3StoriesIndex | V4Index | Unsupp throw new Error(`Unsupported version ${index.v}`); } - const { includeTags, excludeTags, skipTags } = getTagOptions(); + const { includeTags, excludeTags, skipTags } = await getTagOptions(); const titleIdToTest = Object.entries(titleIdToEntries).reduce>( (acc, [titleId, entries]) => { diff --git a/src/setup-page.ts b/src/setup-page.ts index ab9cd03a..296f3316 100644 --- a/src/setup-page.ts +++ b/src/setup-page.ts @@ -4,7 +4,8 @@ import { pkgUp } from 'pkg-up'; import { PrepareContext } from './playwright/hooks'; import { getTestRunnerConfig } from './util/getTestRunnerConfig'; import { readFile } from 'node:fs/promises'; -import path from 'node:path'; +import path, { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; /** * This is a default prepare function which can be overridden by the user. @@ -36,7 +37,10 @@ export const setupPage = async (page: Page, browserContext: BrowserContext) => { const failOnConsole = process.env.TEST_CHECK_CONSOLE; const viewMode = process.env.VIEW_MODE ?? 'story'; - const renderedEvent = viewMode === 'docs' ? 'globalThis.__STORYBOOK_MODULE_CORE_EVENTS__.DOCS_RENDERED' : 'globalThis.__STORYBOOK_MODULE_CORE_EVENTS__.STORY_FINISHED ?? globalThis.__STORYBOOK_MODULE_CORE_EVENTS__.STORY_RENDERED'; + const renderedEvent = + viewMode === 'docs' + ? 'globalThis.__STORYBOOK_MODULE_CORE_EVENTS__.DOCS_RENDERED' + : 'globalThis.__STORYBOOK_MODULE_CORE_EVENTS__.STORY_FINISHED ?? globalThis.__STORYBOOK_MODULE_CORE_EVENTS__.STORY_RENDERED'; const { packageJson } = (await readPackageUp()) as NormalizedReadResult; const { version: testRunnerVersion } = packageJson; @@ -51,7 +55,7 @@ export const setupPage = async (page: Page, browserContext: BrowserContext) => { ); } - const testRunnerConfig = getTestRunnerConfig() || {}; + const testRunnerConfig = (await getTestRunnerConfig()) || {}; if (testRunnerConfig?.prepare) { await testRunnerConfig.prepare({ page, browserContext, testRunnerConfig }); } else { @@ -69,16 +73,18 @@ export const setupPage = async (page: Page, browserContext: BrowserContext) => { return message; }); + const cwd = typeof __dirname === 'string' ? __dirname : dirname(fileURLToPath(import.meta.url)); + const finalStorybookUrl = referenceURL ?? targetURL ?? ''; - const testRunnerPackageLocation = await pkgUp({ cwd: __dirname }); + const testRunnerPackageLocation = await pkgUp({ cwd }); if (!testRunnerPackageLocation) throw new Error('Could not find test-runner package location'); const scriptLocation = path.join( path.dirname(testRunnerPackageLocation), 'dist', - 'setup-page-script.mjs' + 'setup-page-script.js' ); - // read the content of setup-page-script.mjs and replace the placeholders with the actual values + // read the content of setup-page-script.js and replace the placeholders with the actual values const content = (await readFile(scriptLocation, 'utf-8')) .replaceAll('{{storybookUrl}}', finalStorybookUrl) .replaceAll('{{failOnConsole}}', failOnConsole ?? 'false') diff --git a/src/test-storybook.ts b/src/test-storybook.ts index 8004e530..bc215630 100644 --- a/src/test-storybook.ts +++ b/src/test-storybook.ts @@ -1,22 +1,28 @@ #!/usr/bin/env node -import fs from 'fs'; -import { execSync } from 'child_process'; -import fetch from 'node-fetch'; -import canBindToHost from 'can-bind-to-host'; -import dedent from 'ts-dedent'; -import path, { join, resolve } from 'path'; -import tempy from 'tempy'; +import { canBindToHost } from 'can-bind-to-host'; +import { glob } from 'glob'; +import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import path, { join, resolve } from 'pathe'; import { getInterpretedFile } from 'storybook/internal/common'; import { readConfig } from 'storybook/internal/csf-tools'; import { telemetry } from 'storybook/internal/telemetry'; -import { glob } from 'glob'; +import tempy from 'tempy'; +import dedent from 'ts-dedent'; -import { JestOptions, getCliOptions } from './util/getCliOptions'; -import { getStorybookMetadata } from './util/getStorybookMetadata'; -import { getTestRunnerConfig } from './util/getTestRunnerConfig'; -import { transformPlaywrightJson } from './playwright/transformPlaywrightJson'; -import { TestRunnerConfig } from './playwright/hooks'; +import { TestRunnerConfig } from './playwright/hooks.js'; +import { transformPlaywrightJson } from './playwright/transformPlaywrightJson.js'; +import { JestOptions, getCliOptions } from './util/getCliOptions.js'; +import { getStorybookMetadata } from './util/getStorybookMetadata.js'; +import { getTestRunnerConfig } from './util/getTestRunnerConfig.js'; + +// Get the current file's directory in ESM +const __filename2 = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename2); +const require = createRequire(import.meta.url); // Do this as the first thing so that any code reading it knows the right env. process.env.BABEL_ENV = 'test'; @@ -48,10 +54,11 @@ const cleanup = () => { } }; -function getNycBinPath() { - const nycPath = path.join(require.resolve('nyc/package.json')); - const nycBin = require(nycPath).bin.nyc; - const nycBinFullPath = path.join(path.dirname(nycPath), nycBin); +async function getNycBinPath() { + const nycPkgUrl = new URL('nyc/package.json', import.meta.url); + const { bin } = await import(nycPkgUrl.href, { assert: { type: 'json' } }); + const nycBin = bin.nyc; + const nycBinFullPath = path.join(path.dirname(fileURLToPath(nycPkgUrl)), nycBin); return nycBinFullPath; } @@ -80,11 +87,11 @@ async function reportCoverage() { // --check-coverage if we want to break if coverage reaches certain threshold // .nycrc will be respected for thresholds etc. https://www.npmjs.com/package/nyc#coverage-thresholds if (process.env.JEST_SHARD !== 'true') { - const nycBinFullPath = getNycBinPath(); + const nycBinFullPath = await getNycBinPath(); execSync( `node ${nycBinFullPath} report --reporter=text --reporter=lcov -t ${coverageFolder} --report-dir ${coverageFolder}`, { - stdio: 'inherit', + stdio: 'pipe', cwd: process.cwd(), } ); @@ -124,12 +131,14 @@ function sanitizeURL(url: string) { async function executeJestPlaywright(args: JestOptions) { // Always prefer jest installed via the test runner. If it's hoisted, it will get it from root node_modules - const jestPath = path.dirname( - require.resolve('jest', { - paths: [path.join(__dirname, '../@storybook/test-runner/node_modules')], - }) + const jestPath = path.join( + path.dirname( + require.resolve('jest/package.json', { + paths: [path.join(__dirname, '../@storybook/test-runner/node_modules')], + }) + ), + 'bin/jest.js' ); - const jest = require(jestPath); const argv = args.slice(2); // jest configs could either come in the root dir, or inside of the Storybook config dir @@ -145,16 +154,48 @@ async function executeJestPlaywright(args: JestOptions) { userDefinedJestConfig || path.resolve(__dirname, path.join('..', 'playwright', 'test-runner-jest.config.js')); - argv.push('--config', jestConfigPath); + const command = `node --experimental-vm-modules "${jestPath}" ${argv.join( + ' ' + )} --config "${jestConfigPath}" --color`; + + const child = spawn(command, { + shell: true, + cwd: process.cwd(), + stdio: ['inherit', 'pipe'], + }); + const shouldLog = (str: string) => { + return ( + !str.includes('watchman') && + !str.includes('DeprecationWarning') && + !str.includes('ExperimentalWarning') + ); + }; + const exitCode = await new Promise((resolve) => { + // filter out messages like DeprecationWarning and ExperimentalWarning + child.stdout?.on('data', (data) => { + const str = data.toString(); + if (shouldLog(str)) { + process.stdout.write(data); + } + }); + child.stderr?.on('data', (data) => { + const str = data.toString(); + if (shouldLog(str)) { + process.stderr.write(data); + } + }); + + child.on('exit', (exitCode) => resolve(exitCode || 0)); + }); - await jest.run(argv); + process.exit(exitCode); } async function checkStorybook(url: string) { try { const headers = await getHttpHeaders(url); const res = await fetch(url, { method: 'GET', headers }); - if (res.status !== 200) throw new Error(`Unxpected status: ${res.status}`); + if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`); } catch (e) { console.error( dedent`\x1b[31m[test-storybook]\x1b[0m It seems that your Storybook instance is not running at: ${url}. Are you sure it's running? @@ -212,7 +253,7 @@ async function getIndexTempDir(url: string) { let tmpDir: string; try { const indexJson = await getIndexJson(url); - const titleIdToTest = transformPlaywrightJson(indexJson); + const titleIdToTest = await transformPlaywrightJson(indexJson); tmpDir = tempy.directory(); for (const [titleId, test] of Object.entries(titleIdToTest)) { @@ -246,7 +287,9 @@ function ejectConfiguration() { } // copy contents of origin and replace ../dist with @storybook/test-runner - const content = fs.readFileSync(origin, 'utf-8').replace(/..\/dist/g, '@storybook/test-runner'); + const content = fs + .readFileSync(origin, 'utf-8') + .replace('../dist/index.js', '@storybook/test-runner'); fs.writeFileSync(destination, content); log(`Configuration file successfully generated at ${destination}`); } @@ -282,7 +325,8 @@ const main = async () => { process.env.STORYBOOK_CONFIG_DIR = runnerOptions.configDir; - const testRunnerConfig = getTestRunnerConfig(runnerOptions.configDir) ?? ({} as TestRunnerConfig); + const testRunnerConfig = + (await getTestRunnerConfig(runnerOptions.configDir)) ?? ({} as TestRunnerConfig); if (testRunnerConfig.preVisit && testRunnerConfig.preRender) { throw new Error( @@ -378,7 +422,7 @@ const main = async () => { } const { storiesPaths, lazyCompilation, disableTelemetry, enableCrashReports } = - getStorybookMetadata(); + await getStorybookMetadata(); if (!shouldRunIndexJson) { process.env.STORYBOOK_STORIES_PATTERN = storiesPaths; diff --git a/src/util/__snapshots__/getStorybookMain.test.ts.snap b/src/util/__snapshots__/getStorybookMain.test.ts.snap index c06d0310..480469f0 100644 --- a/src/util/__snapshots__/getStorybookMain.test.ts.snap +++ b/src/util/__snapshots__/getStorybookMain.test.ts.snap @@ -1,15 +1,15 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`getStorybookMain no stories should throw an error if no stories are defined 1`] = ` -"Could not find stories in main.js in ".storybook". +exports[`getStorybookMain > no stories > should throw an error if no stories are defined 1`] = ` +[Error: Could not find stories in main.js in ".storybook". If you are using a mono-repository, please run the test-runner only against your sub-package, which contains a .storybook folder with "stories" defined in main.js. -You can change the config directory by using --config-dir " +You can change the config directory by using --config-dir ] `; -exports[`getStorybookMain no stories should throw an error if stories list is empty 1`] = ` -"Could not find stories in main.js in ".storybook". +exports[`getStorybookMain > no stories > should throw an error if stories list is empty 1`] = ` +[Error: Could not find stories in main.js in ".storybook". If you are using a mono-repository, please run the test-runner only against your sub-package, which contains a .storybook folder with "stories" defined in main.js. -You can change the config directory by using --config-dir " +You can change the config directory by using --config-dir ] `; -exports[`getStorybookMain should throw an error if no configuration is found 1`] = `"Could not load main.js in .storybook. Is the ".storybook" config directory correct? You can change it by using --config-dir "`; +exports[`getStorybookMain > should throw an error if no configuration is found 1`] = `[Error: Module not found]`; diff --git a/src/util/getCliOptions.test.ts b/src/util/getCliOptions.test.ts index 47e6eddf..b5f2a44a 100644 --- a/src/util/getCliOptions.test.ts +++ b/src/util/getCliOptions.test.ts @@ -1,5 +1,6 @@ import { getCliOptions } from './getCliOptions'; import * as cliHelper from './getParsedCliOptions'; +import { describe, it, expect, afterEach, vi } from 'vitest'; describe('getCliOptions', () => { const originalArgv: string[] = process.argv; @@ -10,15 +11,16 @@ describe('getCliOptions', () => { it('returns custom options if passed', () => { const customConfig = { configDir: 'custom', indexJson: true }; - jest - .spyOn(cliHelper, 'getParsedCliOptions') - .mockReturnValueOnce({ options: customConfig, extraArgs: [] }); + vi.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValueOnce({ + options: customConfig, + extraArgs: [], + }); const opts = getCliOptions(); expect(opts.runnerOptions).toMatchObject(customConfig); }); it('returns default options if no options are passed', () => { - jest.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ options: {}, extraArgs: [] }); + vi.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ options: {}, extraArgs: [] }); const opts = getCliOptions(); const jestOptions = opts.jestOptions.length > 0 ? ['--coverage'] : []; expect(opts).toEqual({ @@ -29,33 +31,36 @@ describe('getCliOptions', () => { it('returns failOnConsole option if passed', () => { const customConfig = { failOnConsole: true }; - jest - .spyOn(cliHelper, 'getParsedCliOptions') - .mockReturnValue({ options: customConfig, extraArgs: [] }); + vi.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ + options: customConfig, + extraArgs: [], + }); const opts = getCliOptions(); expect(opts.runnerOptions).toMatchObject(customConfig); }); it('handles boolean options correctly', () => { const customConfig = { coverage: true, junit: false }; - jest - .spyOn(cliHelper, 'getParsedCliOptions') - .mockReturnValue({ options: customConfig, extraArgs: [] }); + vi.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ + options: customConfig, + extraArgs: [], + }); const opts = getCliOptions(); expect(opts).toEqual({ jestOptions: [], runnerOptions: { coverage: true, junit: false } }); }); it('handles string options correctly', () => { const customConfig = { url: 'http://localhost:3000' }; - jest - .spyOn(cliHelper, 'getParsedCliOptions') - .mockReturnValue({ options: customConfig, extraArgs: [] }); + vi.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ + options: customConfig, + extraArgs: [], + }); const opts = getCliOptions(); expect(opts).toEqual({ jestOptions: [], runnerOptions: { url: 'http://localhost:3000' } }); }); it('handles extra arguments correctly', () => { - jest.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ + vi.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ options: { version: true, cache: false, env: 'node' } as any, extraArgs: ['--watch', '--coverage'], }); @@ -75,7 +80,7 @@ describe('getCliOptions', () => { // mock argv to avoid side effect from running tests e.g. jest --coverage, // which would end up caught by getCliOptions process.argv = []; - jest.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValueOnce({ options: {}, extraArgs }); + vi.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValueOnce({ options: {}, extraArgs }); const opts = getCliOptions(); expect(opts.jestOptions).toEqual(extraArgs); }); diff --git a/src/util/getCliOptions.ts b/src/util/getCliOptions.ts index af4e4a12..13fa5b0e 100644 --- a/src/util/getCliOptions.ts +++ b/src/util/getCliOptions.ts @@ -1,5 +1,5 @@ import { getParsedCliOptions } from './getParsedCliOptions'; -import type { BrowserType } from 'jest-playwright-preset'; +import type { BrowserType } from '../jest-playwright-preset/types'; export type JestOptions = string[]; diff --git a/src/util/getParsedCliOptions.test.ts b/src/util/getParsedCliOptions.test.ts index 6f34bd6a..7a9183e5 100644 --- a/src/util/getParsedCliOptions.test.ts +++ b/src/util/getParsedCliOptions.test.ts @@ -1,9 +1,10 @@ import { program } from 'commander'; import { getParsedCliOptions } from './getParsedCliOptions'; +import { describe, it, expect, afterEach, vi } from 'vitest'; describe('getParsedCliOptions', () => { afterEach(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it('should return the parsed CLI options', () => { @@ -41,10 +42,10 @@ describe('getParsedCliOptions', () => { it('should handle unknown options', () => { const originalWarn = console.warn; - console.warn = jest.fn(); + console.warn = vi.fn(); const originalExit = process.exit; - process.exit = jest.fn() as unknown as typeof process.exit; + process.exit = vi.fn() as unknown as typeof process.exit; const argv = process.argv.slice(); process.argv.push('--unknown-option'); @@ -62,7 +63,7 @@ describe('getParsedCliOptions', () => { }); it('handles unknown options correctly', () => { - jest.spyOn(program, 'parse').mockImplementation(() => { + vi.spyOn(program, 'parse').mockImplementation(() => { throw new Error('Unknown error'); }); diff --git a/src/util/getStorybookMain.test.ts b/src/util/getStorybookMain.test.ts index 0ac9dc8f..b4e02566 100644 --- a/src/util/getStorybookMain.test.ts +++ b/src/util/getStorybookMain.test.ts @@ -1,32 +1,39 @@ import { getStorybookMain, resetStorybookMainCache, storybookMainConfig } from './getStorybookMain'; -import * as coreCommon from 'storybook/internal/common'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { serverRequire } from 'storybook/internal/common'; -jest.mock('storybook/internal/common'); +vi.mock('storybook/internal/common', () => ({ + serverRequire: vi.fn(), +})); describe('getStorybookMain', () => { beforeEach(() => { resetStorybookMainCache(); + vi.clearAllMocks(); }); - it('should throw an error if no configuration is found', () => { - expect(() => getStorybookMain('.storybook')).toThrowErrorMatchingSnapshot(); + it('should throw an error if no configuration is found', async () => { + vi.mocked(serverRequire).mockRejectedValueOnce(new Error('Module not found')); + await expect(getStorybookMain('.storybook')).rejects.toThrowErrorMatchingSnapshot(); }); describe('no stories', () => { - it('should throw an error if no stories are defined', () => { - jest.spyOn(coreCommon, 'serverRequire').mockImplementation(() => ({})); + it('should throw an error if no stories are defined', async () => { + vi.mocked(serverRequire).mockResolvedValueOnce({}); - expect(() => getStorybookMain('.storybook')).toThrowErrorMatchingSnapshot(); + await expect(getStorybookMain('.storybook')).rejects.toThrowErrorMatchingSnapshot(); }); - it('should throw an error if stories list is empty', () => { - jest.spyOn(coreCommon, 'serverRequire').mockImplementation(() => ({ stories: [] })); + it('should throw an error if stories list is empty', async () => { + vi.mocked(serverRequire).mockResolvedValueOnce({ + stories: [], + }); - expect(() => getStorybookMain('.storybook')).toThrowErrorMatchingSnapshot(); + await expect(getStorybookMain('.storybook')).rejects.toThrowErrorMatchingSnapshot(); }); }); - it('should return mainjs', () => { + it('should return mainjs', async () => { const mockedMain = { stories: [ { @@ -36,13 +43,13 @@ describe('getStorybookMain', () => { ], }; - jest.spyOn(coreCommon, 'serverRequire').mockImplementation(() => mockedMain); + vi.mocked(serverRequire).mockResolvedValueOnce(mockedMain); - const res = getStorybookMain('.storybook'); + const res = await getStorybookMain('.storybook'); expect(res).toMatchObject(mockedMain); }); - it('should return the configDir value if it exists', () => { + it('should return the configDir value if it exists', async () => { const mockedMain = { stories: [ { @@ -51,9 +58,9 @@ describe('getStorybookMain', () => { }, ], }; - storybookMainConfig.set('configDir', mockedMain); + storybookMainConfig.set('.storybook', mockedMain); - const res = getStorybookMain('.storybook'); + const res = await getStorybookMain('.storybook'); expect(res).toMatchObject(mockedMain); }); }); diff --git a/src/util/getStorybookMain.ts b/src/util/getStorybookMain.ts index 90d66c16..5db693a6 100644 --- a/src/util/getStorybookMain.ts +++ b/src/util/getStorybookMain.ts @@ -5,32 +5,39 @@ import dedent from 'ts-dedent'; export const storybookMainConfig = new Map(); -export const getStorybookMain = (configDir = '.storybook') => { +export const getStorybookMain = async (configDir = '.storybook') => { if (storybookMainConfig.has(configDir)) { return storybookMainConfig.get(configDir) as StorybookConfig; - } else { - storybookMainConfig.set(configDir, serverRequire(join(resolve(configDir), 'main'))); } - const mainConfig = storybookMainConfig.get(configDir); + try { + const mainConfig = await serverRequire(join(resolve(configDir), 'main')); + if (!mainConfig) { + throw new Error( + `Could not load main.js in ${configDir}. Is the "${configDir}" config directory correct? You can change it by using --config-dir ` + ); + } - if (!mainConfig) { - throw new Error( - `Could not load main.js in ${configDir}. Is the "${configDir}" config directory correct? You can change it by using --config-dir ` - ); - } + if (!mainConfig.stories || mainConfig.stories.length === 0) { + throw new Error( + dedent` + Could not find stories in main.js in "${configDir}". + If you are using a mono-repository, please run the test-runner only against your sub-package, which contains a .storybook folder with "stories" defined in main.js. + You can change the config directory by using --config-dir + ` + ); + } - if (!mainConfig.stories || mainConfig.stories.length === 0) { + storybookMainConfig.set(configDir, mainConfig); + return mainConfig; + } catch (error) { + if (error instanceof Error) { + throw error; + } throw new Error( - dedent` - Could not find stories in main.js in "${configDir}". - If you are using a mono-repository, please run the test-runner only against your sub-package, which contains a .storybook folder with "stories" defined in main.js. - You can change the config directory by using --config-dir - ` + `Could not load main.js in ${configDir}. Is the "${configDir}" config directory correct? You can change it by using --config-dir ` ); } - - return mainConfig; }; export function resetStorybookMainCache() { diff --git a/src/util/getStorybookMetadata.test.ts b/src/util/getStorybookMetadata.test.ts index 1a5c103a..3f13d6f0 100644 --- a/src/util/getStorybookMetadata.test.ts +++ b/src/util/getStorybookMetadata.test.ts @@ -2,11 +2,11 @@ import { StorybookConfig } from 'storybook/internal/types'; import * as storybookMain from './getStorybookMain'; import { getStorybookMetadata } from './getStorybookMetadata'; +import { describe, it, expect, afterAll, vi } from 'vitest'; -jest.mock('storybook/internal/common', () => ({ - ...jest.requireActual('storybook/internal/common'), - getProjectRoot: jest.fn(() => '/foo/bar'), - normalizeStories: jest.fn(() => [ +vi.mock('storybook/internal/common', () => ({ + getProjectRoot: vi.fn(() => '/foo/bar'), + normalizeStories: vi.fn(() => [ { titlePrefix: 'Example', files: '**/*.stories.@(mdx|tsx|ts|jsx|js)', @@ -22,18 +22,18 @@ describe('getStorybookMetadata', () => { process.env.STORYBOOK_CONFIG_DIR = undefined; }); - it('should return configDir coming from environment variable', () => { + it('should return configDir coming from environment variable', async () => { const mockedMain: Pick = { stories: [], }; - jest.spyOn(storybookMain, 'getStorybookMain').mockReturnValueOnce(mockedMain); + vi.spyOn(storybookMain, 'getStorybookMain').mockResolvedValueOnce(mockedMain); process.env.STORYBOOK_CONFIG_DIR = '.storybook'; - const { configDir } = getStorybookMetadata(); + const { configDir } = await getStorybookMetadata(); expect(configDir).toEqual(process.env.STORYBOOK_CONFIG_DIR); }); - it('should return storiesPath with default glob from CSF3 style config', () => { + it('should return storiesPath with default glob from CSF3 style config', async () => { const mockedMain: Pick = { stories: [ { @@ -43,28 +43,28 @@ describe('getStorybookMetadata', () => { ], }; - jest.spyOn(storybookMain, 'getStorybookMain').mockReturnValueOnce(mockedMain); + vi.spyOn(storybookMain, 'getStorybookMain').mockResolvedValueOnce(mockedMain); process.env.STORYBOOK_CONFIG_DIR = '.storybook'; - const { storiesPaths } = getStorybookMetadata(); + const { storiesPaths } = await getStorybookMetadata(); expect(storiesPaths).toMatchInlineSnapshot( `"/foo/bar/stories/basic/**/*.stories.@(mdx|tsx|ts|jsx|js)"` ); }); - it('should return storiesPath with glob defined in CSF2 style config', () => { + it('should return storiesPath with glob defined in CSF2 style config', async () => { const mockedMain: Pick = { stories: ['../**/stories/*.stories.@(js|ts)'], }; - jest.spyOn(storybookMain, 'getStorybookMain').mockReturnValueOnce(mockedMain); + vi.spyOn(storybookMain, 'getStorybookMain').mockResolvedValueOnce(mockedMain); process.env.STORYBOOK_CONFIG_DIR = '.storybook'; - const { storiesPaths } = getStorybookMetadata(); + const { storiesPaths } = await getStorybookMetadata(); expect(storiesPaths).toMatchInlineSnapshot( `"/foo/bar/stories/basic/**/*.stories.@(mdx|tsx|ts|jsx|js)"` ); }); - it('should return storiesPath from mixed CSF2 and CSF3 style config', () => { + it('should return storiesPath from mixed CSF2 and CSF3 style config', async () => { const mockedMain: Pick = { stories: [ { @@ -75,29 +75,29 @@ describe('getStorybookMetadata', () => { ], }; - jest.spyOn(storybookMain, 'getStorybookMain').mockReturnValueOnce(mockedMain); + vi.spyOn(storybookMain, 'getStorybookMain').mockResolvedValueOnce(mockedMain); process.env.STORYBOOK_CONFIG_DIR = '.storybook'; - const { storiesPaths } = getStorybookMetadata(); + const { storiesPaths } = await getStorybookMetadata(); expect(storiesPaths).toMatchInlineSnapshot( `"/foo/bar/stories/basic/**/*.stories.@(mdx|tsx|ts|jsx|js)"` ); }); - it('should return lazyCompilation=false when unset', () => { + it('should return lazyCompilation=false when unset', async () => { const mockedMain: Pick = { stories: [] }; - jest.spyOn(storybookMain, 'getStorybookMain').mockReturnValueOnce(mockedMain); + vi.spyOn(storybookMain, 'getStorybookMain').mockResolvedValueOnce(mockedMain); process.env.STORYBOOK_CONFIG_DIR = '.storybook'; - expect(getStorybookMetadata().lazyCompilation).toBe(false); + expect((await getStorybookMetadata()).lazyCompilation).toBe(false); }); - it('should return lazyCompilation=true when set', () => { + it('should return lazyCompilation=true when set', async () => { const mockedMain: Pick = { stories: [], core: { builder: { name: 'webpack5', options: { lazyCompilation: true } } }, }; - jest.spyOn(storybookMain, 'getStorybookMain').mockReturnValueOnce(mockedMain); + vi.spyOn(storybookMain, 'getStorybookMain').mockResolvedValueOnce(mockedMain); process.env.STORYBOOK_CONFIG_DIR = '.storybook'; - expect(getStorybookMetadata().lazyCompilation).toBe(true); + expect((await getStorybookMetadata()).lazyCompilation).toBe(true); }); }); diff --git a/src/util/getStorybookMetadata.ts b/src/util/getStorybookMetadata.ts index bf496bd8..c35d1ebf 100644 --- a/src/util/getStorybookMetadata.ts +++ b/src/util/getStorybookMetadata.ts @@ -4,11 +4,11 @@ import { StoriesEntry } from 'storybook/internal/types'; import { getStorybookMain } from './getStorybookMain'; -export const getStorybookMetadata = () => { +export const getStorybookMetadata = async () => { const workingDir = getProjectRoot(); const configDir = process.env.STORYBOOK_CONFIG_DIR ?? '.storybook'; - const main = getStorybookMain(configDir); + const main = await getStorybookMain(configDir); const normalizedStoriesEntries = normalizeStories(main.stories as StoriesEntry[], { configDir, workingDir, @@ -22,10 +22,8 @@ export const getStorybookMetadata = () => { .map((dir) => join(workingDir, dir)) .join(';'); - // @ts-expect-error -- this is added in storybook/internal/common@6.5, which we don't depend on const lazyCompilation = !!main.core?.builder?.options?.lazyCompilation; - // @ts-expect-error -- need to update to latest sb version const { disableTelemetry, enableCrashReports } = main.core || {}; return { diff --git a/src/util/getTagOptions.ts b/src/util/getTagOptions.ts index 0a72d942..2be39a02 100644 --- a/src/util/getTagOptions.ts +++ b/src/util/getTagOptions.ts @@ -11,8 +11,8 @@ type TagOptions = { * 1. Test runner config 'tags' object * 2. Environment variables (takes precedence) */ -export function getTagOptions() { - const config = getTestRunnerConfig(); +export async function getTagOptions() { + const config = await getTestRunnerConfig(); let tagOptions = { includeTags: config?.tags?.include || ['test'], diff --git a/src/util/getTestRunnerConfig.test.ts b/src/util/getTestRunnerConfig.test.ts index 0361d5eb..28bbf740 100644 --- a/src/util/getTestRunnerConfig.test.ts +++ b/src/util/getTestRunnerConfig.test.ts @@ -1,6 +1,8 @@ import { TestRunnerConfig } from '../playwright/hooks'; import { getTestRunnerConfig } from './getTestRunnerConfig'; import { join, resolve } from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { serverRequire } from 'storybook/internal/common'; const testRunnerConfig: TestRunnerConfig = { setup: () => { @@ -26,45 +28,41 @@ const testRunnerConfig: TestRunnerConfig = { }, }; -jest.mock('storybook/internal/common', () => ({ - serverRequire: jest.fn(), +vi.mock('storybook/internal/common', () => ({ + serverRequire: vi.fn(), })); describe('getTestRunnerConfig', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); - it('should load the test runner config', () => { + it('should load the test runner config', async () => { const configDir = '.storybook'; - (require('storybook/internal/common').serverRequire as jest.Mock).mockReturnValueOnce( - testRunnerConfig - ); + vi.mocked(serverRequire).mockResolvedValueOnce(testRunnerConfig); - const result = getTestRunnerConfig(configDir); + const result = await getTestRunnerConfig(configDir); expect(result).toEqual(testRunnerConfig); - expect(require('storybook/internal/common').serverRequire).toHaveBeenCalledWith( + expect(vi.mocked(serverRequire)).toHaveBeenCalledWith( join(resolve('.storybook', 'test-runner')) ); }); - it('should cache the test runner config', () => { + it('should cache the test runner config', async () => { const configDir = '.storybook'; - (require('storybook/internal/common').serverRequire as jest.Mock).mockReturnValueOnce( - testRunnerConfig - ); + vi.mocked(serverRequire).mockResolvedValueOnce(testRunnerConfig); - const result1 = getTestRunnerConfig(configDir); - const result2 = getTestRunnerConfig(configDir); + const result1 = await getTestRunnerConfig(configDir); + const result2 = await getTestRunnerConfig(configDir); expect(result1).toEqual(testRunnerConfig); expect(result2).toEqual(testRunnerConfig); }); - it('should load the test runner config with default configDir', () => { + it('should load the test runner config with default configDir', async () => { process.env.STORYBOOK_CONFIG_DIR = '.storybook'; - const result = getTestRunnerConfig(); + const result = await getTestRunnerConfig(); expect(result).toEqual(testRunnerConfig); }); diff --git a/src/util/getTestRunnerConfig.ts b/src/util/getTestRunnerConfig.ts index 4b3307ad..43f0eb1c 100644 --- a/src/util/getTestRunnerConfig.ts +++ b/src/util/getTestRunnerConfig.ts @@ -5,15 +5,15 @@ import { TestRunnerConfig } from '../playwright/hooks'; let testRunnerConfig: TestRunnerConfig; let loaded = false; -export const getTestRunnerConfig = ( +export const getTestRunnerConfig = async ( configDir = process.env.STORYBOOK_CONFIG_DIR ?? '.storybook' -): TestRunnerConfig | undefined => { +): Promise => { // testRunnerConfig can be undefined if (loaded) { return testRunnerConfig; } - testRunnerConfig = serverRequire(join(resolve(configDir), 'test-runner')); + testRunnerConfig = await serverRequire(join(resolve(configDir), 'test-runner')); loaded = true; return testRunnerConfig; }; diff --git a/stories/atoms/__snapshots__/Button.stories.tsx.snap b/stories/atoms/__snapshots__/Button.stories.tsx.snap index dab58ccb..0fd0fab4 100644 --- a/stories/atoms/__snapshots__/Button.stories.tsx.snap +++ b/stories/atoms/__snapshots__/Button.stories.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Atoms/Button Demo play-test 2`] = `