diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 381dde0d..0e96bbe2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,8 +19,9 @@ jobs: - name: Install dependencies uses: bahmutov/npm-install@v1 - - name: Run jest tests + - name: Run unit tests run: | + yarn build yarn test --coverage - name: Install Playwright Browsers diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 00000000..51e5b163 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,21 @@ +name: Typecheck + +on: [push, pull_request] + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js 18.x + uses: actions/setup-node@v1 + with: + node-version: '18.x' + + - name: Install dependencies + uses: bahmutov/npm-install@v1 + + - name: Type check + run: yarn tsc + diff --git a/jest.config.js b/jest.config.js index 851b11be..fec48d9a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,11 @@ module.exports = { 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/custom-environment': + '/playwright/custom-environment', + '@storybook/test-runner/playwright/jest-setup': '/playwright/jest-setup', + '@storybook/test-runner/playwright/transform': '/playwright/transform', + }, }; diff --git a/src/config/jest-playwright.test.ts b/src/config/jest-playwright.test.ts new file mode 100644 index 00000000..b382a6da --- /dev/null +++ b/src/config/jest-playwright.test.ts @@ -0,0 +1,115 @@ +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 4f25b058..144bc4ee 100644 --- a/src/config/jest-playwright.ts +++ b/src/config/jest-playwright.ts @@ -85,7 +85,7 @@ export const getJestConfig = () => { snapshotSerializers: [jestSerializerHtmlPath], testEnvironmentOptions: { 'jest-playwright': { - browsers: TEST_BROWSERS.split(',') + browsers: TEST_BROWSERS?.split(',') .map((p) => p.trim().toLowerCase()) .filter(Boolean), collectCoverage: STORYBOOK_COLLECT_COVERAGE === 'true', diff --git a/src/csf/__snapshots__/transformCsf.test.ts.snap b/src/csf/__snapshots__/transformCsf.test.ts.snap new file mode 100644 index 00000000..5e4d7b32 --- /dev/null +++ b/src/csf/__snapshots__/transformCsf.test.ts.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`transformCsf calls the beforeEachPrefixer function once 1`] = ` +" + export default { + title: 'Button', + parameters: { + play: { + steps: [ + { id: 'step1', action: 'click', target: 'button' }, + { id: 'step2', action: 'click', target: 'button' }, + ], + }, + }, + }; + 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" + }; + const onPageError = err => { + globalThis.__sbThrowUncaughtPageError(err, context); + }; + page.on('pageerror', onPageError); + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + const result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "button--primary" + }); + 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); + } + page.off('pageerror', onPageError); + 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`] = ` +" + export default { + title: 'Button', + parameters: { + play: { + steps: [ + { id: 'step1', action: 'click', target: 'button' }, + { id: 'step2', action: 'click', target: 'button' }, + ], + }, + }, + }; + 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" + }; + const onPageError = err => { + globalThis.__sbThrowUncaughtPageError(err, context); + }; + page.on('pageerror', onPageError); + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + const result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "button--primary" + }); + 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); + } + page.off('pageerror', onPageError); + 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`] = ` +" +if (!require.main) { + describe("Button", () => { + describe("Primary", () => { + it("smoke-test", async () => { + const testFn = async () => { + const context = { + id: "button--primary", + title: "Button", + name: "Primary" + }; + const onPageError = err => { + globalThis.__sbThrowUncaughtPageError(err, context); + }; + page.on('pageerror', onPageError); + if (globalThis.__sbPreVisit) { + await globalThis.__sbPreVisit(page, context); + } + const result = await page.evaluate(({ + id, + hasPlayFn + }) => __test(id, hasPlayFn), { + id: "button--primary" + }); + 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); + } + page.off('pageerror', onPageError); + 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`] = ` +" + export default { + title: 'Button', + parameters: { + play: { + steps: [ + { id: 'step1', action: 'click', target: 'button' }, + { id: 'step2', action: 'click', target: 'button' }, + ], + }, + }, + }; + export const Primary = () => ''; + + +if (!require.main) { + describe("Button", () => { + beforeEach(beforeEach(() => { + console.log("beforeEach called"); + })); + describe("Primary", () => { + it("smoke-test", async () => {}); + }); + }); +}" +`; diff --git a/src/csf/transformCsf.test.ts b/src/csf/transformCsf.test.ts new file mode 100644 index 00000000..15c600b7 --- /dev/null +++ b/src/csf/transformCsf.test.ts @@ -0,0 +1,109 @@ +import { TestPrefixer, TransformOptions, transformCsf } from './transformCsf'; +import { testPrefixer } from '../playwright/transformPlaywright'; +import template from '@babel/template'; + +describe('transformCsf', () => { + it('inserts a no-op test if there are no stories', () => { + const csfCode = ` + export default { + title: 'Button', + }; + `; + const expectedCode = `describe.skip('Button', () => { it('no-op', () => {}) });`; + + const result = transformCsf(csfCode, { insertTestIfEmpty: true } as TransformOptions); + + expect(result).toEqual(expectedCode); + }); + + it('calls the testPrefixer function for each test', () => { + const csfCode = ` + export default { + title: 'Button', + parameters: { + play: { + steps: [ + { id: 'step1', action: 'click', target: 'button' }, + { id: 'step2', action: 'click', target: 'button' }, + ], + }, + }, + }; + export const Primary = () => ''; + `; + + const result = transformCsf(csfCode, { testPrefixer }); + + expect(result).toMatchSnapshot(); + }); + + it('calls the beforeEachPrefixer function once', () => { + const csfCode = ` + export default { + title: 'Button', + parameters: { + play: { + steps: [ + { id: 'step1', action: 'click', target: 'button' }, + { id: 'step2', action: 'click', target: 'button' }, + ], + }, + }, + }; + export const Primary = () => ''; + `; + const result = transformCsf(csfCode, { testPrefixer, beforeEachPrefixer: undefined }); + + expect(result).toMatchSnapshot(); + }); + + it('clears the body if clearBody option is true', () => { + const csfCode = ` + export default { + title: 'Button', + parameters: { + play: { + steps: [ + { id: 'step1', action: 'click', target: 'button' }, + { id: 'step2', action: 'click', target: 'button' }, + ], + }, + }, + }; + export const Primary = () => ''; + `; + + const result = transformCsf(csfCode, { testPrefixer, clearBody: true }); + + expect(result).toMatchSnapshot(); + }); + + it('executes beforeEach code before each test', () => { + const code = ` + export default { + title: 'Button', + parameters: { + play: { + steps: [ + { id: 'step1', action: 'click', target: 'button' }, + { id: 'step2', action: 'click', target: 'button' }, + ], + }, + }, + }; + export const Primary = () => ''; + `; + const beforeEachPrefixer = () => { + const logStatement = template.expression`console.log("beforeEach called")`; + const beforeEachBlock = template.statement`beforeEach(() => { ${logStatement()} })`; + return beforeEachBlock(); + }; + const testPrefixer = template(` + console.log({ id: %%id%%, title: %%title%%, name: %%name%%, storyExport: %%storyExport%% }); + async () => {}`) as unknown as TestPrefixer; + + const result = transformCsf(code, { beforeEachPrefixer, testPrefixer } as TransformOptions); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/csf/transformCsf.ts b/src/csf/transformCsf.ts index 4b3fd8af..f7ce7761 100644 --- a/src/csf/transformCsf.ts +++ b/src/csf/transformCsf.ts @@ -4,6 +4,7 @@ import * as t from '@babel/types'; import generate from '@babel/generator'; import { toId, storyNameFromExport } from '@storybook/csf'; import dedent from 'ts-dedent'; + import { getTagOptions } from '../util/getTagOptions'; export interface TestContext { @@ -16,10 +17,10 @@ type TemplateResult = t.Statement | t.Statement[]; type FilePrefixer = () => TemplateResult; export type TestPrefixer = (context: TestContext) => TemplateResult; -interface TransformOptions { +export interface TransformOptions { clearBody?: boolean; beforeEachPrefixer?: FilePrefixer; - testPrefixer?: TestPrefixer; + testPrefixer: TestPrefixer; insertTestIfEmpty?: boolean; makeTitle?: (userTitle: string) => string; includeTags?: string[]; @@ -27,12 +28,7 @@ interface TransformOptions { skipTags?: string[]; } -const prefixFunction = ( - key: string, - title: string, - input: t.Expression, - testPrefixer?: TestPrefixer -) => { +export const prefixFunction = (key: string, title: string, testPrefixer: TestPrefixer) => { const name = storyNameFromExport(key); const context: TestContext = { storyExport: t.identifier(key), @@ -55,15 +51,15 @@ const makePlayTest = ({ }: { key: string; title: string; - metaOrStoryPlay: t.Node; - testPrefix?: TestPrefixer; + metaOrStoryPlay?: boolean; + testPrefix: TestPrefixer; shouldSkip?: boolean; }): t.Statement[] => { return [ t.expressionStatement( t.callExpression(shouldSkip ? t.identifier('it.skip') : t.identifier('it'), [ t.stringLiteral(!!metaOrStoryPlay ? 'play-test' : 'smoke-test'), - prefixFunction(key, title, metaOrStoryPlay as t.Expression, testPrefix), + prefixFunction(key, title, testPrefix), ]) ), ]; @@ -100,15 +96,15 @@ export const transformCsf = ( beforeEachPrefixer, insertTestIfEmpty, makeTitle, - }: TransformOptions = {} + }: TransformOptions ) => { const { includeTags, excludeTags, skipTags } = getTagOptions(); - const csf = loadCsf(code, { makeTitle }); + const csf = loadCsf(code, { makeTitle: makeTitle || ((userTitle: string) => userTitle) }); csf.parse(); const storyExports = Object.keys(csf._stories); - const title = csf.meta.title; + const title = csf.meta?.title; const storyAnnotations = storyExports.reduce((acc, key) => { const annotations = csf._storyAnnotations[key]; @@ -117,7 +113,7 @@ export const transformCsf = ( acc[key].play = annotations.play; } - acc[key].tags = csf._stories[key].tags || csf.meta.tags || []; + acc[key].tags = csf._stories[key].tags || csf.meta?.tags || []; return acc; }, {} as Record); @@ -126,40 +122,42 @@ export const transformCsf = ( // If includeTags is passed, check if the story has any of them - else include by default const isIncluded = includeTags.length === 0 || - includeTags.some((tag) => storyAnnotations[key].tags.includes(tag)); + includeTags.some((tag) => storyAnnotations[key].tags?.includes(tag)); // If excludeTags is passed, check if the story does not have any of them - const isNotExcluded = excludeTags.every((tag) => !storyAnnotations[key].tags.includes(tag)); + const isNotExcluded = excludeTags.every((tag) => !storyAnnotations[key].tags?.includes(tag)); return isIncluded && isNotExcluded; }) .map((key: string) => { let tests: t.Statement[] = []; - const shouldSkip = skipTags.some((tag) => storyAnnotations[key].tags.includes(tag)); - tests = [ - ...tests, - ...makePlayTest({ - key, - title, - metaOrStoryPlay: storyAnnotations[key].play, - testPrefix: testPrefixer, - shouldSkip, - }), - ]; + const shouldSkip = skipTags.some((tag) => storyAnnotations[key].tags?.includes(tag)); + if (title) { + tests = [ + ...tests, + ...makePlayTest({ + key, + title, + metaOrStoryPlay: !!storyAnnotations[key]?.play, + testPrefix: testPrefixer, + shouldSkip, + }), + ]; + } if (tests.length) { return makeDescribe(key, tests); } return null; }) - .filter(Boolean); + .filter(Boolean) as babel.types.Statement[]; let result = ''; if (!clearBody) result = `${result}${code}\n`; if (allTests.length) { const describe = makeDescribe( - csf.meta.title, + csf.meta?.title as string, allTests, beforeEachPrefixer ? makeBeforeEach(beforeEachPrefixer) : undefined ) as babel.types.Node; @@ -173,7 +171,7 @@ export const transformCsf = ( } else if (insertTestIfEmpty) { // When there are no tests at all, we skip. The reason is that the file already went through Jest's transformation, // so we have to skip the describe to achieve a "excluded test" experience. - result = `describe.skip('${csf.meta.title}', () => { it('no-op', () => {}) });`; + result = `describe.skip('${csf.meta?.title}', () => { it('no-op', () => {}) });`; } return result; }; diff --git a/src/playwright/hooks.test.ts b/src/playwright/hooks.test.ts new file mode 100644 index 00000000..673f445e --- /dev/null +++ b/src/playwright/hooks.test.ts @@ -0,0 +1,117 @@ +import { Page } from 'playwright-core'; +import { + getStoryContext, + setPreVisit, + setPostVisit, + TestRunnerConfig, + waitForPageReady, +} from './hooks'; + +type MockPage = Page & { evaluate: jest.Mock }; + +describe('test-runner', () => { + describe('setPreVisit', () => { + it('sets the preVisit function', () => { + const preVisit = jest.fn(); + setPreVisit(preVisit); + expect(globalThis.__sbPreVisit).toBe(preVisit); + }); + }); + + describe('setPostVisit', () => { + it('sets the postVisit function', () => { + const postVisit = jest.fn(); + setPostVisit(postVisit); + expect(globalThis.__sbPostVisit).toBe(postVisit); + }); + }); + + describe('getStoryContext', () => { + const page = { + evaluate: jest.fn(), + } as MockPage; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls page.evaluate with the correct arguments', async () => { + const context = { id: 'id', title: 'title', name: 'name' }; + await getStoryContext(page, context); + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), { storyId: context.id }); + }); + + it('returns the result of page.evaluate', async () => { + const context = { id: 'id', title: 'title', name: 'name' }; + const storyContext = { kind: 'kind', name: 'name' }; + page.evaluate.mockResolvedValueOnce(storyContext); + const result = await getStoryContext(page, context); + expect(result).toBe(storyContext); + }); + + it('calls globalThis.__getContext with the correct storyId', async () => { + const context = { id: 'id', title: 'title', name: 'name' }; + const storyContext = { kind: 'kind', name: 'name' }; + + // Mock globalThis.__getContext + globalThis.__getContext = jest.fn(); + + page.evaluate.mockImplementation(async (func) => { + // Call the function passed to page.evaluate + func({ storyId: context.id }); + return storyContext; + }); + + await getStoryContext(page, context); + + // Check that globalThis.__getContext was called with the correct storyId + expect(globalThis.__getContext).toHaveBeenCalledWith(context.id); + }); + }); + + describe('TestRunnerConfig', () => { + it('has the correct properties', () => { + const config: TestRunnerConfig = {}; + expect(config).toMatchObject({}); + }); + }); + + describe('waitForPageReady', () => { + let page: Page; + + beforeEach(() => { + page = { + waitForLoadState: jest.fn(), + evaluate: jest.fn(), + } as unknown as Page; + }); + + it('waits for the page to be ready', async () => { + await waitForPageReady(page); + expect(page.waitForLoadState).toHaveBeenCalledWith('domcontentloaded'); + expect(page.waitForLoadState).toHaveBeenCalledWith('load'); + expect(page.waitForLoadState).toHaveBeenCalledWith('networkidle'); + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('calls page.evaluate with () => document.fonts.ready', async () => { + const page = { + waitForLoadState: jest.fn(), + evaluate: jest.fn(), + } as unknown as MockPage; + + // Mock document.fonts.ready + globalThis.document = { + fonts: { + ready: 'ready', + }, + } as unknown as Document; + await waitForPageReady(page); + + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function)); + const evaluateFn = page.evaluate.mock.calls[0][0]; + const mockDocument = { fonts: { ready: 'ready' } }; + expect(evaluateFn(mockDocument)).toBe('ready'); + }); + }); +}); diff --git a/src/playwright/transformPlaywright.ts b/src/playwright/transformPlaywright.ts index 11911bfb..326b37b4 100644 --- a/src/playwright/transformPlaywright.ts +++ b/src/playwright/transformPlaywright.ts @@ -69,13 +69,14 @@ export const testPrefixer = template( { plugins: ['jsx'], } -) as any as TestPrefixer; +) as unknown as TestPrefixer; const makeTitleFactory = (filename: string) => { const { workingDir, normalizedStoriesEntries } = getStorybookMetadata(); const filePath = './' + relative(workingDir, filename); - return (userTitle: string) => userOrAutoTitle(filePath, normalizedStoriesEntries, userTitle); + return (userTitle: string) => + userOrAutoTitle(filePath, normalizedStoriesEntries, userTitle) as string; }; export const transformPlaywright = (src: string, filename: string) => { diff --git a/src/setup-page.ts b/src/setup-page.ts index 31d7c220..8bbe4fa5 100644 --- a/src/setup-page.ts +++ b/src/setup-page.ts @@ -1,5 +1,5 @@ import type { Page, BrowserContext } from 'playwright'; -import readPackageUp from 'read-pkg-up'; +import readPackageUp, { NormalizedReadResult } from 'read-pkg-up'; import { PrepareContext } from './playwright/hooks'; import { getTestRunnerConfig } from './util/getTestRunnerConfig'; @@ -34,7 +34,7 @@ export const setupPage = async (page: Page, browserContext: BrowserContext) => { const viewMode = process.env.VIEW_MODE || 'story'; const renderedEvent = viewMode === 'docs' ? 'docsRendered' : 'storyRendered'; - const { packageJson } = await readPackageUp(); + const { packageJson } = (await readPackageUp()) as NormalizedReadResult; const { version: testRunnerVersion } = packageJson; const referenceURL = process.env.REFERENCE_URL; @@ -48,7 +48,7 @@ export const setupPage = async (page: Page, browserContext: BrowserContext) => { ); } - const testRunnerConfig = getTestRunnerConfig(); + const testRunnerConfig = getTestRunnerConfig() || {}; if (testRunnerConfig?.prepare) { await testRunnerConfig.prepare({ page, browserContext, testRunnerConfig }); } else { diff --git a/src/test-storybook.ts b/src/test-storybook.ts index e46d2771..5c4bcae5 100644 --- a/src/test-storybook.ts +++ b/src/test-storybook.ts @@ -37,12 +37,8 @@ process.on('unhandledRejection', (err) => { const log = (message: string) => console.log(`[test-storybook] ${message}`); const warn = (message: string) => console.warn('\x1b[33m%s\x1b[0m', `[test-storybook] ${message}`); -const error = (err: { message: any; stack: any }) => { - if (err instanceof Error) { - console.error(`\x1b[31m[test-storybook]\x1b[0m ${err.message} \n\n${err.stack}`); - } else { - console.error(`\x1b[31m[test-storybook]\x1b[0m ${err}`); - } +const error = (err: Error) => { + console.error(`\x1b[31m[test-storybook]\x1b[0m ${err.message} \n\n${err.stack}`); }; // Clean up tmp files globally in case of control-c @@ -182,7 +178,7 @@ async function executeJestPlaywright(args: JestOptions) { await jest.run(argv); } -async function checkStorybook(url: any) { +async function checkStorybook(url: string) { try { const headers = await getHttpHeaders(url); const res = await fetch(url, { method: 'GET', headers }); @@ -252,7 +248,12 @@ async function getIndexTempDir(url: string) { fs.writeFileSync(tmpFile, test as string); }); } catch (err) { - error(err); + if (err instanceof Error) { + error(err); + } else { + error(new Error(JSON.stringify(err))); + } + process.exit(1); } return tmpDir; diff --git a/src/util/__snapshots__/getStorybookMain.test.ts.snap b/src/util/__snapshots__/getStorybookMain.test.ts.snap index 52aadfce..c06d0310 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 exports[`getStorybookMain no stories should throw an error if no stories are defined 1`] = ` -"Could not find stories in main.js in .storybook. +"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 " `; exports[`getStorybookMain no stories should throw an error if stories list is empty 1`] = ` -"Could not find stories in main.js in .storybook. +"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 " `; -exports[`getStorybookMain should throw an error if no configuration is found 1`] = `"Could not load main.js in .storybook. Is the config directory correct? You can change it 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 "`; diff --git a/src/util/getCliOptions.test.ts b/src/util/getCliOptions.test.ts index e9e663a0..d5956a1c 100644 --- a/src/util/getCliOptions.test.ts +++ b/src/util/getCliOptions.test.ts @@ -17,6 +17,16 @@ describe('getCliOptions', () => { expect(opts.runnerOptions).toMatchObject(customConfig); }); + it('returns default options if no options are passed', () => { + jest.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ options: {}, extraArgs: [] }); + const opts = getCliOptions(); + const jestOptions = opts.jestOptions.length > 0 ? ['--coverage'] : []; + expect(opts).toEqual({ + runnerOptions: {}, + jestOptions, + }); + }); + it('returns failOnConsole option if passed', () => { const customConfig = { failOnConsole: true }; jest @@ -26,6 +36,40 @@ describe('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: [] }); + 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: [] }); + const opts = getCliOptions(); + expect(opts).toEqual({ jestOptions: [], runnerOptions: { url: 'http://localhost:3000' } }); + }); + + it('handles extra arguments correctly', () => { + jest.spyOn(cliHelper, 'getParsedCliOptions').mockReturnValue({ + options: { version: true, cache: false, env: 'node' } as any, + extraArgs: ['--watch', '--coverage'], + }); + const opts = getCliOptions(); + expect(opts.jestOptions).toEqual([ + '--version', + '--no-cache', + '--env', + 'node', + '--watch', + '--coverage', + ]); + }); + it('returns extra args if passed', () => { const extraArgs = ['TestName', 'AnotherTestName']; // mock argv to avoid side effect from running tests e.g. jest --coverage, diff --git a/src/util/getCliOptions.ts b/src/util/getCliOptions.ts index 5e0ddfd7..6da687e5 100644 --- a/src/util/getCliOptions.ts +++ b/src/util/getCliOptions.ts @@ -54,16 +54,19 @@ export const getCliOptions = (): CliOptions => { jestOptions: process.argv.splice(0, 2), }; - const finalOptions = Object.keys(allOptions).reduce((acc, key: StorybookRunnerCommand) => { + const finalOptions = Object.keys(allOptions).reduce((acc: CliOptions, _key: string) => { + let key = _key as StorybookRunnerCommand; + let optionValue = allOptions[key]; + if (STORYBOOK_RUNNER_COMMANDS.includes(key)) { - copyOption(acc.runnerOptions, key, allOptions[key]); + copyOption(acc.runnerOptions, key, optionValue); } else { - if (allOptions[key] === true) { + if (optionValue === true) { acc.jestOptions.push(`--${key}`); - } else if (allOptions[key] === false) { + } else if (optionValue === false) { acc.jestOptions.push(`--no-${key}`); } else { - acc.jestOptions.push(`--${key}`, allOptions[key] as string); + acc.jestOptions.push(`--${key}`, `${optionValue}`); } } diff --git a/src/util/getParsedCliOptions.test.ts b/src/util/getParsedCliOptions.test.ts new file mode 100644 index 00000000..3ee51258 --- /dev/null +++ b/src/util/getParsedCliOptions.test.ts @@ -0,0 +1,73 @@ +import { program } from 'commander'; +import { getParsedCliOptions } from './getParsedCliOptions'; + +describe('getParsedCliOptions', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return the parsed CLI options', () => { + const parsedCliOptions = getParsedCliOptions(); + const coverageEnabled = parsedCliOptions.options.coverage ?? false; + if (coverageEnabled) { + expect(parsedCliOptions).toEqual({ + options: { + indexJson: undefined, + configDir: '.storybook', + coverageDirectory: 'coverage/storybook', + watch: false, + browsers: ['chromium'], + url: 'http://127.0.0.1:6006', + cache: true, + coverage: true, + }, + extraArgs: [], + }); + } else { + expect(parsedCliOptions).toEqual({ + options: { + indexJson: undefined, + configDir: '.storybook', + coverageDirectory: 'coverage/storybook', + watch: false, + browsers: ['chromium'], + url: 'http://127.0.0.1:6006', + cache: true, + }, + extraArgs: [], + }); + } + }); + + it('should handle unknown options', () => { + const originalWarn = console.warn; + console.warn = jest.fn(); + + const originalExit = process.exit; + process.exit = jest.fn() as any; + + const argv = process.argv.slice(); + process.argv.push('--unknown-option'); + + expect(() => { + getParsedCliOptions(); + }).toThrow(); + + expect(console.warn).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + + process.argv = argv; + console.warn = originalWarn; + process.exit = originalExit; + }); + + it('handles unknown options correctly', () => { + jest.spyOn(program, 'parse').mockImplementation(() => { + throw new Error('Unknown error'); + }); + + expect(() => { + getParsedCliOptions(); + }).toThrow(Error); + }); +}); diff --git a/src/util/getParsedCliOptions.ts b/src/util/getParsedCliOptions.ts index e1971d7a..65475c0d 100644 --- a/src/util/getParsedCliOptions.ts +++ b/src/util/getParsedCliOptions.ts @@ -1,5 +1,5 @@ import type { CliOptions } from './getCliOptions'; -import { program } from 'commander'; +import { CommanderError, program } from 'commander'; type ParsedCliOptions = { options: CliOptions['runnerOptions']; @@ -85,23 +85,27 @@ export const getParsedCliOptions = (): ParsedCliOptions => { try { program.parse(); - } catch (err) { - switch (err.code) { - case 'commander.unknownOption': { - program.outputHelp(); - console.warn( - `\nIf you'd like this option to be supported, please open an issue at https://github.com/storybookjs/test-runner/issues/new\n` - ); - process.exit(1); - } + } catch (err: unknown) { + if (err instanceof CommanderError) { + switch (err.code) { + case 'commander.unknownOption': { + program.outputHelp(); + console.warn( + `\nIf you'd like this option to be supported, please open an issue at https://github.com/storybookjs/test-runner/issues/new\n` + ); + process.exit(1); + } - case 'commander.helpDisplayed': { - process.exit(0); - } + case 'commander.helpDisplayed': { + process.exit(0); + } - default: { - throw err; + default: { + throw err; + } } + } else { + throw err; } } diff --git a/src/util/getStorybookMain.ts b/src/util/getStorybookMain.ts index 288bec0d..eb4ea145 100644 --- a/src/util/getStorybookMain.ts +++ b/src/util/getStorybookMain.ts @@ -5,9 +5,9 @@ import dedent from 'ts-dedent'; let storybookMainConfig = new Map(); -export const getStorybookMain = (configDir: string) => { +export const getStorybookMain = (configDir = '.storybook') => { if (storybookMainConfig.has(configDir)) { - return storybookMainConfig.get(configDir); + return storybookMainConfig.get(configDir) as StorybookConfig; } else { storybookMainConfig.set(configDir, serverRequire(join(resolve(configDir), 'main'))); } @@ -16,14 +16,14 @@ export const getStorybookMain = (configDir: string) => { if (!mainConfig) { throw new Error( - `Could not load main.js in ${configDir}. Is the config directory correct? You can change it 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 ` ); } if (!mainConfig.stories || mainConfig.stories.length === 0) { throw new Error( dedent` - Could not find stories in main.js in ${configDir}. + 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 ` diff --git a/src/util/getStorybookMetadata.ts b/src/util/getStorybookMetadata.ts index 7ac02571..a7e4de9c 100644 --- a/src/util/getStorybookMetadata.ts +++ b/src/util/getStorybookMetadata.ts @@ -1,13 +1,14 @@ import { join } from 'path'; import { normalizeStories, getProjectRoot } from '@storybook/core-common'; import { getStorybookMain } from './getStorybookMain'; +import { StoriesEntry } from '@storybook/types'; export const getStorybookMetadata = () => { const workingDir = getProjectRoot(); - const configDir = process.env.STORYBOOK_CONFIG_DIR; + const configDir = process.env.STORYBOOK_CONFIG_DIR || '.storybook'; const main = getStorybookMain(configDir); - const normalizedStoriesEntries = normalizeStories(main.stories, { + const normalizedStoriesEntries = normalizeStories(main.stories as StoriesEntry[], { configDir, workingDir, }).map((specifier) => ({ @@ -21,7 +22,7 @@ export const getStorybookMetadata = () => { .join(';'); // @ts-ignore -- this is added in @storybook/core-common@6.5, which we don't depend on - const lazyCompilation = !!main?.core?.builder?.options?.lazyCompilation; + const lazyCompilation = !!main.core?.builder?.options?.lazyCompilation; return { configDir, diff --git a/src/util/getTestRunnerConfig.test.ts b/src/util/getTestRunnerConfig.test.ts new file mode 100644 index 00000000..91670849 --- /dev/null +++ b/src/util/getTestRunnerConfig.test.ts @@ -0,0 +1,74 @@ +import { TestRunnerConfig } from '../playwright/hooks'; +import { getTestRunnerConfig } from './getTestRunnerConfig'; +import { join, resolve } from 'path'; + +const testRunnerConfig: TestRunnerConfig = { + setup: () => { + console.log('Running setup'); + }, + preRender: async (page) => { + console.log('Running preRender'); + await page.goto('https://example.com'); + }, + postRender: async (page) => { + console.log('Running postRender'); + const title = await page.title(); + console.log(`Page title: ${title}`); + }, + getHttpHeaders: async (url: string) => { + console.log(`Getting http headers for ${url}`); + const headers = { Authorization: 'Bearer token' }; + return headers; + }, + prepare: async ({ page }) => { + console.log('Preparing browser'); + await page.setViewportSize({ width: 1920, height: 1080 }); + }, +}; + +jest.mock('@storybook/core-common', () => ({ + serverRequire: jest.fn(), +})); + +describe('getTestRunnerConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should load the test runner config', () => { + const configDir = '.storybook'; + (require('@storybook/core-common').serverRequire as jest.Mock).mockReturnValueOnce( + testRunnerConfig + ); + + const result = getTestRunnerConfig(configDir); + + expect(result).toEqual(testRunnerConfig); + expect(require('@storybook/core-common').serverRequire).toHaveBeenCalledWith( + join(resolve('.storybook', 'test-runner')) + ); + }); + + it('should cache the test runner config', () => { + const configDir = '.storybook'; + (require('@storybook/core-common').serverRequire as jest.Mock).mockReturnValueOnce( + testRunnerConfig + ); + + const result1 = getTestRunnerConfig(configDir); + const result2 = getTestRunnerConfig(configDir); + + expect(result1).toEqual(testRunnerConfig); + expect(result2).toEqual(testRunnerConfig); + }); + + it('should load the test runner config with default configDir', () => { + process.env.STORYBOOK_CONFIG_DIR = '.storybook'; + const result = getTestRunnerConfig(); + expect(result).toEqual(testRunnerConfig); + }); + + afterEach(() => { + delete process.env.STORYBOOK_CONFIG_DIR; + }); +}); diff --git a/src/util/getTestRunnerConfig.ts b/src/util/getTestRunnerConfig.ts index fc4b33c9..237f9543 100644 --- a/src/util/getTestRunnerConfig.ts +++ b/src/util/getTestRunnerConfig.ts @@ -6,7 +6,7 @@ let testRunnerConfig: TestRunnerConfig; let loaded = false; export const getTestRunnerConfig = ( - configDir: string = process.env.STORYBOOK_CONFIG_DIR + configDir = process.env.STORYBOOK_CONFIG_DIR || '.storybook' ): TestRunnerConfig | undefined => { // testRunnerConfig can be undefined if (loaded) { diff --git a/tsconfig.json b/tsconfig.json index 9637798a..ddc1f0da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,9 @@ "skipLibCheck": true, "target": "es2020", "types": ["jest", "node"], - "moduleResolution": "node" + "moduleResolution": "node", + "strict": true, + "noEmit": true, }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.test.ts"] diff --git a/tsup.config.ts b/tsup.config.ts index 007276bb..0e0ae64d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,7 +3,11 @@ import { defineConfig } from 'tsup'; export default defineConfig([ { clean: true, - entry: ['./src/**/!(*.{d,test}).{js,jsx,ts,tsx}'], + entry: [ + './src/**/*.{js,jsx,ts,tsx}', + '!./src/**/*.d.{js,jsx,ts,tsx}', + '!./src/**/*test.{js,jsx,ts,tsx}', + ], format: ['cjs', 'esm'], splitting: false, dts: true,