import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' import { basename, dirname, extname, join, relative } from 'path' import { loadStorybookCommon, loadStorybookCsfTools, type StaticStory, type StoriesConfig, type StoriesEntry } from '../storybook-internals' import { createMixedFactoryDetection, detectUnsupportedCsfNext, getFactoryStoryExportsFromAst, isMixedFactoryError, UnsupportedCsfNextError } from './csf-format' import { findStoriesToTest as findCsfsToTest } from './find-stories-to-test' export async function generateTests ({ projectRoot, storybookConfigDirectory, testDirectory }: { projectRoot: string; storybookConfigDirectory: string; testDirectory: string; }) { const csfPatterns = await getCsfPatterns(storybookConfigDirectory, projectRoot) const csfsToTest = await findCsfsToTest( csfPatterns, { projectRoot, storybookConfigDirectory } ) const generatedTests: Array<{ outDir: string, outFile: string, jestTest: string }> = [] for (const csfToTest of csfsToTest) { const code = readFileSync(csfToTest, { encoding: 'utf-8' }) const storiesInCsf = await parseCsf(code, csfToTest, csfPatterns, storybookConfigDirectory) const jestTest = generateJestTest(csfToTest, storiesInCsf) // make spec path unique by mirroring the CSF relative path under .detox-tests const relCsfPath = relative(projectRoot, csfToTest) generatedTests.push({ outDir: join(testDirectory, dirname(relCsfPath)), outFile: join(testDirectory, dirname(relCsfPath), `${basename(csfToTest, extname(csfToTest))}.spec.js`), jestTest }) } rmSync(testDirectory, { recursive: true, force: true }) mkdirSync(testDirectory, { recursive: true }) for (const { outDir, outFile, jestTest } of generatedTests) { mkdirSync(outDir, { recursive: true }) writeFileSync(outFile, jestTest) } return { csfsToTest } } async function getCsfPatterns (configDir: string, projectRoot: string) { const { loadMainConfig } = await loadStorybookCommon() let mainConfig: { stories?: StoriesConfig } | undefined try { mainConfig = await loadMainConfig({ configDir, cwd: projectRoot }) } catch { throw new Error(`Could not load main.js in ${configDir}.`) } return await resolveStories(mainConfig?.stories, configDir, projectRoot) } async function resolveStories ( stories: StoriesConfig | undefined, storybookConfigDirectory: string, projectRoot: string ) { if (typeof stories === 'function') { let resolvedStories: unknown try { resolvedStories = await stories([], { configDir: storybookConfigDirectory, cwd: projectRoot }) } catch (error: any) { throw new Error(`Could not load stories from main.js in "${storybookConfigDirectory}": ${error?.message ?? String(error)}`) } return validateStories(resolvedStories, storybookConfigDirectory) } return validateStories(stories, storybookConfigDirectory) } function validateStories (stories: unknown, storybookConfigDirectory: string) { if (!Array.isArray(stories) || stories.length === 0) { throw new Error(`Could not find stories in main.js in "${storybookConfigDirectory}".`) } return stories as StoriesEntry[] } async function parseCsf (code: string, csfFilePath: string, csfPatterns: StoriesEntry[], storybookConfigDirectory: string) { const { getStoryTitle } = await loadStorybookCommon() const { loadCsf } = await loadStorybookCsfTools() const csf = loadCsf(code, { fileName: csfFilePath, makeTitle (userTitle) { return getStoryTitle({ storyFilePath: csfFilePath, configDir: storybookConfigDirectory, stories: csfPatterns, userTitle }) || 'unknown' } }) const fallbackFactoryStoryExports = getFactoryStoryExportsFromAst(csf) let parsed try { parsed = csf.parse() } catch (error) { if (isMixedFactoryError(error)) { throw new UnsupportedCsfNextError( createMixedFactoryDetection(csfFilePath, fallbackFactoryStoryExports), error ) } const unsupportedCsfNext = detectUnsupportedCsfNext(csf, undefined, csfFilePath, fallbackFactoryStoryExports) if (unsupportedCsfNext) { throw new UnsupportedCsfNextError(unsupportedCsfNext, error) } throw error } const unsupportedCsfNext = detectUnsupportedCsfNext(csf, parsed, csfFilePath, fallbackFactoryStoryExports) if (unsupportedCsfNext) { throw new UnsupportedCsfNextError(unsupportedCsfNext) } return parsed._stories } function generateJestTest (csfFilePath: string, stories: Record) { return ` // This file is auto-generated by storybook-detox-test-runner. const story = require('${csfFilePath}') const channel = require('${join(__dirname, '..', 'channel.js')}') const detox = require('detox') beforeAll(async () => { await channel.routeFromDeviceToServer() }) function testOrSkip (onlyOnOperatingSystems) { if (onlyOnOperatingSystems === undefined) { return test } return onlyOnOperatingSystems.includes(device.getPlatform()) ? test : test.skip } ${Object.entries(stories) .map(([variableName, story]) => generateTestForStory(variableName, story)) .join('')}` } function generateTestForStory (variableName: string, story: StaticStory) { return ` testOrSkip(story.${variableName}.detox?.onlyOnOperatingSystems)('${story.name}', async function () { await device.launchApp(story.${variableName}.detox?.launch) await channel.changeStory('${story.id}') await story.${variableName}.play?.({ detox }) })\n\n` }