feat: index.html configurability and storybook support (#18242)

This commit is contained in:
Zachary Williams
2021-10-08 13:16:30 -05:00
committed by GitHub
parent 837977f902
commit 745b3ac451
41 changed files with 12205 additions and 93 deletions
@@ -5,15 +5,17 @@
"start": "react-scripts start",
"test": "node ../../../../scripts/cypress run-ct"
},
"dependencies": {
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-scripts": "4.0.3"
},
"devDependencies": {
"@cypress/react": "file:../../dist",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"mocha-junit-reporter": "^2.0.0",
"mocha-multi-reporters": "^1.5.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-scripts": "4.0.3",
"typescript": "^4.2.3"
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -8,6 +8,12 @@ declare namespace legacyDevServer {
* @returns modified final configuration
*/
setWebpackConfig?(config:Configuration): Configuration
/**
* Path to an index.html file that will serve as the template in
* which your components will be rendered.
*/
indexHtml?: string
}
/**
+2 -1
View File
@@ -2,10 +2,11 @@ const { startDevServer } = require('@cypress/webpack-dev-server')
const getBabelWebpackConfig = require('./getBabelWebpackConfig')
const { getLegacyDevServer } = require('../utils/legacy-setup-dev-server')
function devServer (cypressDevServerConfig, devServerConfig) {
function devServer (cypressDevServerConfig, devServerConfig = {}) {
return startDevServer({
options: cypressDevServerConfig,
webpackConfig: getBabelWebpackConfig(cypressDevServerConfig.config, devServerConfig),
indexHtml: devServerConfig.indexHtml,
})
}
+6
View File
@@ -4,6 +4,12 @@ declare namespace legacyDevServer {
* The object exported of your craco.config.js file
*/
cracoConfig: any
/**
* Path to an index.html file that will serve as the template in
* which your components will be rendered.
*/
indexHtml?: string
}
/**
+4 -3
View File
@@ -2,10 +2,11 @@ const { startDevServer } = require('@cypress/webpack-dev-server')
const { createWebpackDevConfig } = require('@craco/craco')
const { getLegacyDevServer } = require('../utils/legacy-setup-dev-server')
function devServer (cypressDevServerConfig, cracoConfig) {
function devServer (cypressDevServerConfig, cracoConfig, indexHtml) {
return startDevServer({
options: cypressDevServerConfig,
webpackConfig: createWebpackDevConfig(cracoConfig),
indexHtml,
})
}
@@ -18,8 +19,8 @@ module.exports = getLegacyDevServer(devServer, (config) => {
// New signature
// - Note that this also includes a change to the second argument!
module.exports.devServer = (cypressDevServerConfig, { cracoConfig }) => {
return devServer(cypressDevServerConfig, cracoConfig)
module.exports.devServer = (cypressDevServerConfig, { cracoConfig, indexHtml }) => {
return devServer(cypressDevServerConfig, cracoConfig, indexHtml)
}
module.exports.defineDevServerConfig = function (devServerConfig) {
+6
View File
@@ -4,6 +4,12 @@ declare namespace legacyDevServer {
* Location of the weppack.config Cypress should use
*/
webpackFilename?: string
/**
* Path to an index.html file that will serve as the template in
* which your components will be rendered.
*/
indexHtml?: string
}
/**
+2 -1
View File
@@ -15,7 +15,7 @@ function normalizeWebpackPath (config, webpackConfigPath) {
* **Important:** `webpackFilename` path is relative to the project root (cypress.json location)
* @type {(on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions, options: { webpackFilename: string }) => Cypress.PluginConfigOptions}
*/
function devServer (cypressDevServerConfig, { webpackFilename }) {
function devServer (cypressDevServerConfig, { webpackFilename, indexHtml }) {
const webpackConfig = tryLoadWebpackConfig(normalizeWebpackPath(cypressDevServerConfig.config, webpackFilename))
if (!webpackConfig) {
@@ -25,6 +25,7 @@ function devServer (cypressDevServerConfig, { webpackFilename }) {
return startDevServer({
options: cypressDevServerConfig,
webpackConfig,
indexHtml,
})
}
+22 -1
View File
@@ -6,12 +6,33 @@
declare function legacyDevServer(on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions): void
declare namespace legacyDevServer {
interface CypressNextDevServerConfig {
/**
* Path to an index.html file that will serve as the template in
* which your components will be rendered.
*/
indexHtml?: string
}
/**
* Type helper to make writing `CypressNextDevServerConfig` easier
*/
function defineDevServerConfig(devServerConfig: CypressNextDevServerConfig): CypressNextDevServerConfig
/**
* Sets up a Cypress component testing environment for your NextJs application
* @param cypressDevServerConfig comes from the `devServer()` function first argument
* @returns the resolved dev server object that cypress can use to start testing
*/
function devServer(cypressDevServerConfig: Cypress.DevServerConfig): Cypress.ResolvedDevServerConfig
function devServer(cypressDevServerConfig: Cypress.DevServerConfig, devServerConfig?: CypressNextDevServerConfig): Cypress.ResolvedDevServerConfig
}
/**
* Sets up a Cypress component testing environment for your NextJs application
* @param on comes from the argument of the `pluginsFile` function
* @param config comes from the argument of the `pluginsFile` function
* @param devServerConfig additional config object (create an empty object to see how to use it)
*/
declare function legacyDevServer(on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions, devServerConfig?: legacyDevServer.CypressNextDevServerConfig): void
export = legacyDevServer;
+2 -2
View File
@@ -2,7 +2,7 @@ const path = require('path')
const findNextWebpackConfig = require('./findNextWebpackConfig')
const { getLegacyDevServer } = require('../utils/legacy-setup-dev-server')
async function devServer (cypressDevServerConfig) {
async function devServer (cypressDevServerConfig, { indexHtml } = {}) {
const webpackConfig = await findNextWebpackConfig(cypressDevServerConfig.config)
// require('webpack') now points to nextjs bundled version
@@ -11,7 +11,7 @@ async function devServer (cypressDevServerConfig) {
return startDevServer({
options: cypressDevServerConfig,
webpackConfig,
template: path.resolve(__dirname, 'index-template.html'),
indexHtml: indexHtml || path.resolve(__dirname, 'index-template.html'),
})
}
+6
View File
@@ -4,6 +4,12 @@ declare namespace legacyDevServer {
* Location of the weppack.config Cypress should use
*/
webpackConfigPath?: string
/**
* Path to an index.html file that will serve as the template in
* which your components will be rendered.
*/
indexHtml?: string
}
/**
+4 -4
View File
@@ -4,14 +4,14 @@ const { getLegacyDevServer } = require('../utils/legacy-setup-dev-server')
function devServer (cypressDevServerConfig, {
webpackConfigPath,
} = {
webpackConfigPath: 'react-scripts/config/webpack.config',
}) {
indexHtml,
} = {}) {
return startDevServer({
options: cypressDevServerConfig,
webpackConfig: findReactScriptsWebpackConfig(cypressDevServerConfig.config, {
webpackConfigPath,
webpackConfigPath: webpackConfigPath || 'react-scripts/config/webpack.config',
}),
indexHtml,
})
}
+24 -9
View File
@@ -37,6 +37,7 @@ export const makeCypressPlugin = (
supportFilePath: string | false,
devServerEvents: NodeJS.EventEmitter,
specs: Spec[],
indexHtml?: string,
): Plugin => {
let base = '/'
@@ -47,6 +48,7 @@ export const makeCypressPlugin = (
})
const posixSupportFilePath = supportFilePath ? convertPathToPosix(resolve(projectRoot, supportFilePath)) : undefined
const posixIndexHtml = indexHtml ? convertPathToPosix(resolve(projectRoot, indexHtml)) : undefined
const normalizedSupportFilePath = posixSupportFilePath ? `${base}@fs/${posixSupportFilePath}` : undefined
@@ -66,29 +68,42 @@ export const makeCypressPlugin = (
configResolved (config) {
base = config.base
},
transformIndexHtml () {
async transformIndexHtml () {
debug('transformIndexHtml with base', base)
const indexHtmlPath = indexHtml ? resolve(projectRoot, indexHtml) : resolve(__dirname, '..', 'index.html')
const indexHtmlContent = await read(indexHtmlPath, { encoding: 'utf8' })
return [
return {
html: indexHtmlContent,
// load the script at the end of the body
// script has to be loaded when the vite client is connected
{
tags: [{
tag: 'script',
injectTo: 'body',
attrs: { type: 'module' },
children: `import(${JSON.stringify(`${base}@fs/${INIT_FILEPATH}`)})`,
},
]
}],
}
},
configureServer: async (server: ViteDevServer) => {
const indexHtml = await read(resolve(__dirname, '..', 'index.html'), { encoding: 'utf8' })
server.middlewares.use(`${base}index.html`, async (req, res) => {
const transformedIndexHtml = await server.transformIndexHtml(base, '')
const transformedIndexHtml = await server.transformIndexHtml(base, indexHtml)
server.middlewares.use(`${base}index.html`, (req, res) => res.end(transformedIndexHtml))
return res.end(transformedIndexHtml)
})
},
handleHotUpdate: ({ server, file }) => {
debug('handleHotUpdate - file', file)
// If the user provided IndexHtml is changed, do a full-reload
if (file === posixIndexHtml) {
server.ws.send({
type: 'full-reload',
})
return
}
// get the graph node for the file that just got updated
let moduleImporters = server.moduleGraph.fileToModulesMap.get(file)
let iterationNumber = 0
+8 -2
View File
@@ -18,9 +18,15 @@ export interface StartDevServerOptions {
* @optional
*/
viteConfig?: Omit<InlineConfig, 'base' | 'root'>
/**
* Path to an index.html file that will serve as the template in
* which your components will be rendered.
*/
indexHtml?: string
}
const resolveServerConfig = async ({ viteConfig, options }: StartDevServerOptions): Promise<InlineConfig> => {
const resolveServerConfig = async ({ viteConfig, options, indexHtml }: StartDevServerOptions): Promise<InlineConfig> => {
const { projectRoot, supportFile } = options.config
const requiredOptions: InlineConfig = {
@@ -30,7 +36,7 @@ const resolveServerConfig = async ({ viteConfig, options }: StartDevServerOption
const finalConfig: InlineConfig = { ...viteConfig, ...requiredOptions }
finalConfig.plugins = [...(finalConfig.plugins || []), makeCypressPlugin(projectRoot, supportFile, options.devServerEvents, options.specs)]
finalConfig.plugins = [...(finalConfig.plugins || []), makeCypressPlugin(projectRoot, supportFile, options.devServerEvents, options.specs, indexHtml)]
// This alias is necessary to avoid a "prefixIdentifiers" issue from slots mounting
// only cjs compiler-core accepts using prefixIdentifiers in slots which vue test utils use.
@@ -20,14 +20,14 @@ export interface UserWebpackDevServerOptions {
interface MakeWebpackConfigOptions extends CypressCTOptionsPluginOptionsWithEmitter, UserWebpackDevServerOptions {
devServerPublicPathRoute: string
isOpenMode: boolean
template?: string
indexHtml?: string
}
const OsSeparatorRE = RegExp(`\\${path.sep}`, 'g')
const posixSeparator = '/'
export async function makeWebpackConfig (userWebpackConfig: webpack.Configuration, options: MakeWebpackConfigOptions): Promise<webpack.Configuration> {
const { projectRoot, devServerPublicPathRoute, files, supportFile, devServerEvents, template } = options
const { projectRoot, devServerPublicPathRoute, files, supportFile, devServerEvents, indexHtml } = options
debug(`User passed in webpack config with values %o`, userWebpackConfig)
@@ -80,7 +80,7 @@ export async function makeWebpackConfig (userWebpackConfig: webpack.Configuratio
const mergedConfig = merge<webpack.Configuration>(
userWebpackConfig,
makeDefaultWebpackConfig(template),
makeDefaultWebpackConfig(indexHtml),
dynamicWebpackConfig,
)
+3 -3
View File
@@ -10,12 +10,12 @@ export interface StartDevServer extends UserWebpackDevServerOptions {
/* support passing a path to the user's webpack config */
webpackConfig?: Record<string, any>
/* base html template to render in AUT */
template?: string
indexHtml?: string
}
const debug = Debug('cypress:webpack-dev-server:start')
export async function start ({ webpackConfig: userWebpackConfig, template, options, ...userOptions }: StartDevServer, exitProcess = process.exit): Promise<WebpackDevServer> {
export async function start ({ webpackConfig: userWebpackConfig, indexHtml, options, ...userOptions }: StartDevServer, exitProcess = process.exit): Promise<WebpackDevServer> {
if (!userWebpackConfig) {
debug('User did not pass in any webpack configuration')
}
@@ -24,7 +24,7 @@ export async function start ({ webpackConfig: userWebpackConfig, template, optio
const webpackConfig = await makeWebpackConfig(userWebpackConfig || {}, {
files: options.specs,
template,
indexHtml,
projectRoot,
devServerPublicPathRoute,
devServerEvents: options.devServerEvents,
+2 -2
View File
@@ -5,7 +5,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin')
* @param {string} [template] - base template to use
* @returns {import('webpack').Configuration}
*/
module.exports = function makeDefaultConfig (template) {
module.exports = function makeDefaultConfig (indexHtml) {
return {
mode: 'development',
optimization: {
@@ -18,7 +18,7 @@ module.exports = function makeDefaultConfig (template) {
path: path.resolve(__dirname, 'dist'),
},
plugins: [new HtmlWebpackPlugin({
template: template || path.resolve(__dirname, '..', 'index-template.html'),
template: indexHtml || path.resolve(__dirname, '..', 'index-template.html'),
})],
}
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
@@ -44,6 +44,7 @@ const navigation = [
{ name: 'Specs', icon: SpecsIcon, href: '/' },
{ name: 'Runs', icon: CodeIcon, href: '/runner' },
{ name: 'Settings', icon: SettingsIcon, href: '/settings' },
{ name: 'New Spec', icon: SettingsIcon, href: '/newspec' },
]
defineProps<{
+64
View File
@@ -0,0 +1,64 @@
<template>
<div v-if="query.data.value?.wizard.storybook?.configured">
<h2>New Spec</h2>
<ul v-if="query.data.value.wizard.storybook.stories.length">
<li
v-for="story of query.data.value.wizard.storybook.stories"
:key="story"
@click="storyClick(story)"
>
{{ story }}
</li>
</ul>
<p v-else>
No Stories Detected
</p>
</div>
<div v-else>
Storybook is not configured for this project
</div>
</template>
<route>
{
name: "New Spec Page"
}
</route>
<script lang="ts" setup>
import { gql, useMutation, useQuery } from '@urql/vue'
import { MainQueryDocument, GenerateSpecFromStoryDocument } from '../generated/graphql'
gql`
query MainQuery {
wizard {
storybook {
configured
stories
}
}
}
`
gql`
mutation GenerateSpecFromStory($storyPath: String!) {
generateSpecFromStory (storyPath: $storyPath) {
storybook {
configured,
generatedSpec
}
}
}
`
const query = useQuery({ query: MainQueryDocument })
const mutation = useMutation(GenerateSpecFromStoryDocument)
async function storyClick (story) {
await mutation.executeMutation({ storyPath: story })
const generatedSpec = mutation.data.value?.generateSpecFromStory.storybook?.generatedSpec
// Runner doesn't pick up new file without timeout, I'm guessing a race condition between file watcher and runner starting
setTimeout(() => {
window.location.href = `${window.location.origin}/__/#/tests/component/${generatedSpec}`
}, 500)
}
</script>
+1
View File
@@ -18,6 +18,7 @@
"devDependencies": {
"@packages/ts": "0.0.0-development",
"@packages/types": "0.0.0-development",
"@storybook/csf-tools": "^6.4.0-alpha.38",
"mocha": "7.0.1",
"rimraf": "3.0.2"
},
+6 -1
View File
@@ -1,5 +1,5 @@
import type { DataContext } from '.'
import { AppActions, ProjectActions, WizardActions } from './actions'
import { AppActions, ProjectActions, StorybookActions, WizardActions } from './actions'
import { AuthActions } from './actions/AuthActions'
import { cached } from './util'
@@ -25,4 +25,9 @@ export class DataActions {
get project () {
return new ProjectActions(this.ctx)
}
@cached
get storybook () {
return new StorybookActions(this.ctx)
}
}
+6
View File
@@ -14,6 +14,7 @@ import {
WizardDataSource,
BrowserDataSource,
UtilDataSource,
StorybookDataSource,
} from './sources/'
import { cached } from './util/cached'
@@ -104,6 +105,11 @@ export class DataContext {
return new WizardDataSource(this)
}
@cached
get storybook () {
return new StorybookDataSource(this)
}
get wizardData () {
return this.coreData.wizard
}
@@ -185,4 +185,18 @@ export class ProjectActions {
async clearLatestProjectCache () {
await this.api.clearLatestProjectsCache()
}
createComponentIndexHtml (template: string) {
const project = this.ctx.activeProject
if (!project) {
throw Error(`Cannot create index.html without activeProject.`)
}
if (this.ctx.activeProject?.isFirstTimeCT) {
const indexHtmlPath = path.resolve(this.ctx.activeProject.projectRoot, 'cypress/component/support/index.html')
this.ctx.fs.outputFile(indexHtmlPath, template)
}
}
}
@@ -0,0 +1,138 @@
import type { FoundSpec, FullConfig } from '@packages/types'
import { CsfFile, readCsfOrMdx } from '@storybook/csf-tools'
import endent from 'endent'
import * as path from 'path'
import type { DataContext } from '..'
export class StorybookActions {
constructor (private ctx: DataContext) {}
async generateSpecFromStory (storyPath: string) {
const project = this.ctx.activeProject
if (!project) {
throw Error(`Cannot generate a spec without activeProject.`)
}
const config = await this.ctx.project.getConfig(project.projectRoot)
const spec = await this.generate(storyPath, project.projectRoot, config.componentFolder)
this.ctx.wizardData.generatedSpec = spec
}
private async generate (
storyPath: string,
projectRoot: string,
componentFolder: FullConfig['componentFolder'],
): Promise<FoundSpec | null> {
const storyFile = path.parse(storyPath)
const storyName = storyFile.name.split('.')[0]
try {
const raw = await readCsfOrMdx(storyPath, {
defaultTitle: storyName || '',
})
const parsed = raw.parse()
if (
(!parsed.meta.title && !parsed.meta.component) ||
!parsed.stories.length
) {
return null
}
const specFileExtension = '.cy-spec'
const newSpecContent = this.generateSpecFromCsf(parsed, storyFile)
const newSpecPath = path.join(
storyPath,
'..',
`${parsed.meta.component}${specFileExtension}${storyFile.ext}`,
)
// If this passes then the file exists and we don't want to overwrite it
try {
await this.ctx.fs.access(newSpecPath, this.ctx.fs.constants.F_OK)
return null
} catch (e) {
// eslint-disable-line no-empty
}
await this.ctx.fs.outputFileSync(newSpecPath, newSpecContent)
const parsedSpec = path.parse(newSpecPath)
// Can this be obtained from the spec watcher?
return {
baseName: parsedSpec.base,
fileName: parsedSpec.base.replace(specFileExtension, ''),
specFileExtension,
fileExtension: parsedSpec.ext,
name: path.relative(componentFolder || projectRoot, newSpecPath),
relative: path.relative(projectRoot, newSpecPath),
absolute: newSpecPath,
specType: 'component',
}
} catch (e) {
return null
}
}
private generateSpecFromCsf (parsed: CsfFile, storyFile: path.ParsedPath) {
const isReact = parsed._ast.program.body.some(
(statement) => {
return statement.type === 'ImportDeclaration' &&
statement.source.value === 'react'
},
)
const isVue = parsed._ast.program.body.some(
(statement) => {
return statement.type === 'ImportDeclaration' &&
statement.source.value.endsWith('.vue')
},
)
if (!isReact && !isVue) {
throw new Error('Provided story is not supported')
}
const getDependency = () => {
return endent`
import { mount } from "@cypress/${isReact ? 'react' : 'vue'}"
import { composeStories } from "@storybook/testing-${isReact ? 'react' : 'vue3'}"`
}
const getMountSyntax = (component: string) => {
return isReact ? `<${component} />` : `${component}()`
}
const itContent = parsed.stories
.map((story, i) => {
const component = story.name.replace(/\s+/, '')
let it = endent`
it('should render ${component}', () => {
const { ${component} } = composedStories
mount(${getMountSyntax(component)})
})`
if (i !== 0) {
it = it
.split('\n')
.map((line) => `// ${line}`)
.join('\n')
}
return it
})
.join('\n\n')
return endent`${isReact ? `import React from "react"` : ''}
import * as stories from "./${storyFile.name}"
${getDependency()}
const composedStories = composeStories(stories)
describe('${parsed.meta.title || parsed.meta.component}', () => {
${itContent}
})`
}
}
@@ -5,4 +5,5 @@ export * from './AppActions'
export * from './AuthActions'
export * from './FileActions'
export * from './ProjectActions'
export * from './StorybookActions'
export * from './WizardActions'
@@ -1,4 +1,4 @@
import { BUNDLERS, FoundBrowser, FoundSpec } from '@packages/types'
import { BUNDLERS, FoundBrowser, FoundSpec, StorybookFile } from '@packages/types'
import type { NexusGenEnums } from '@packages/graphql/src/gen/nxs.gen'
export type Maybe<T> = T | null | undefined
@@ -39,6 +39,7 @@ export interface WizardDataShape {
chosenFramework: NexusGenEnums['FrontendFrameworkEnum'] | null
chosenManualInstall: boolean
chosenBrowser: FoundBrowser | null
generatedSpec: Omit<StorybookFile, 'content'> | null
}
export interface CoreDataShape {
@@ -68,6 +69,7 @@ export function makeCoreData (): CoreDataShape {
allBundlers: BUNDLERS,
history: ['welcome'],
chosenBrowser: null,
generatedSpec: null,
},
user: null,
}
@@ -0,0 +1,92 @@
import glob from 'glob'
import * as path from 'path'
import type { DataContext } from '..'
import { promisify } from 'util'
import type { StorybookInfo } from '@packages/types'
const STORYBOOK_FILES = [
'main.js',
'preview.js',
'preview-head.html',
'preview-body.html',
]
export class StorybookDataSource {
constructor (private ctx: DataContext) {}
get storybookInfo () {
if (!this.ctx.activeProject?.projectRoot) {
return Promise.resolve(null)
}
return this.storybookInfoLoader.load(this.ctx.activeProject?.projectRoot)
}
private storybookInfoLoader = this.ctx.loader<string, StorybookInfo | null>((projectRoots) => this.batchStorybookInfo(projectRoots))
private batchStorybookInfo (projectRoots: readonly string[]) {
return Promise.all(projectRoots.map((projectRoot) => this.detectStorybook(projectRoot)))
}
private async detectStorybook (projectRoot: string): Promise<StorybookInfo | null> {
const storybookRoot = path.join(projectRoot, '.storybook')
const storybookInfo: StorybookInfo = {
storybookRoot,
files: [],
storyGlobs: [],
getStories: this.getStories,
}
try {
await this.ctx.fs.access(storybookRoot, this.ctx.fs.constants.F_OK)
} catch {
return null
}
for (const fileName of STORYBOOK_FILES) {
try {
const absolute = path.join(storybookRoot, fileName)
const file = {
name: fileName,
relative: path.relative(projectRoot, absolute),
absolute,
content: await this.ctx.fs.readFile(absolute, 'utf-8'),
}
storybookInfo.files.push(file)
} catch (e) {
// eslint-disable-line no-empty
}
}
const mainJs = storybookInfo.files.find(({ name }) => name === 'main.js')
if (mainJs) {
try {
// Does this need to be wrapped in IPC?
const mainJsModule = require(mainJs.absolute)
storybookInfo.storyGlobs = mainJsModule.stories
} catch (e) {
return null
}
} else {
return null
}
return storybookInfo
}
private async getStories (storybookRoot: string, storyGlobs: string[]) {
const files: string[] = []
for (const storyPattern of storyGlobs) {
const res = await promisify(glob)(path.join(storybookRoot, storyPattern))
files.push(...res)
}
// Don't currently support mdx
return files.filter((file) => !file.endsWith('.mdx'))
}
}
@@ -1,5 +1,6 @@
import { Bundler, BUNDLERS, FrontendFramework, FRONTEND_FRAMEWORKS, PACKAGES_DESCRIPTIONS, WIZARD_STEPS } from '@packages/types'
import { Bundler, BUNDLERS, FrontendFramework, FRONTEND_FRAMEWORKS, PACKAGES_DESCRIPTIONS, StorybookInfo, WIZARD_STEPS } from '@packages/types'
import dedent from 'dedent'
import endent from 'endent'
import type { NexusGenEnums, NexusGenObjects } from '@packages/graphql/src/gen/nxs.gen'
import type { DataContext } from '..'
@@ -74,8 +75,9 @@ export class WizardDataSource {
return true
}
sampleCode (lang: 'js' | 'ts') {
async sampleCode (lang: 'js' | 'ts') {
const data = this.ctx.wizardData
const storybookInfo = await this.ctx.storybook.storybookInfo
if (data.chosenTestingType === 'component') {
if (!this.chosenFramework || !this.chosenBundler) {
@@ -87,6 +89,7 @@ export class WizardDataSource {
framework: this.chosenFramework,
bundler: this.chosenBundler,
lang,
storybookInfo,
})
}
@@ -100,6 +103,20 @@ export class WizardDataSource {
return null
}
async sampleTemplate () {
const storybookInfo = await this.ctx.storybook.storybookInfo
if (!this.chosenFramework || !this.chosenBundler) {
return null
}
return wizardGetComponentIndexHtml({
framework: this.chosenFramework,
bundler: this.chosenBundler,
storybookInfo,
})
}
get chosenTestingType () {
return this.ctx.wizardData.chosenTestingType
}
@@ -125,6 +142,7 @@ interface GetCodeOptsCt {
framework: FrontendFramework
bundler: Bundler
lang: WizardCodeLanguage
storybookInfo?: StorybookInfo | null
}
type GetCodeOpts = GetCodeOptsCt | GetCodeOptsE2E
@@ -162,7 +180,7 @@ const wizardGetConfigCodeCt = (opts: GetCodeOptsCt): string | null => {
const { framework, bundler, lang } = opts
const comments = `Component testing, ${LanguageNames[opts.lang]}, ${framework.name}, ${bundler.name}`
const frameworkConfig = FRAMEWORK_CONFIG_FILE[framework.type]
const frameworkConfig = getFrameworkConfigFile(opts)
if (frameworkConfig) {
return `// ${comments}
@@ -204,63 +222,144 @@ ${exportStatement}
}`
}
const FRAMEWORK_CONFIG_FILE: Partial<Record<NexusGenEnums['FrontendFrameworkEnum'], Record<NexusGenEnums['WizardCodeLanguage'], string> | null>> = {
nextjs: {
js: dedent`
const injectNextDevServer = require('@cypress/react/plugins/next')
const getFrameworkConfigFile = (opts: GetCodeOptsCt) => {
return {
nextjs: {
js: dedent`
const injectNextDevServer = require('@cypress/react/plugins/next')
module.exports = {
component (on, config) {
injectNextDevServer(on, config)
},
}
`,
ts: dedent`
import { defineConfig } from 'cypress'
import injectNextDevServer from '@cypress/react/plugins/next'
module.exports = {
component (on, config) {
injectNextDevServer(on, config)
},
}
`,
ts: dedent`
import { defineConfig } from 'cypress'
import injectNextDevServer from '@cypress/react/plugins/next'
export default defineConfig({
component (on, config) {
injectNextDevServer(on, config)
},
})
`,
},
nuxtjs: {
js: dedent`
const { startDevServer } = require('@cypress/webpack-dev-server')
const { getWebpackConfig } = require('nuxt')
export default defineConfig({
component (on, config) {
injectNextDevServer(on, config)
},
})
`,
},
nuxtjs: {
js: dedent`
const { startDevServer } = require('@cypress/webpack-dev-server')
const { getWebpackConfig } = require('nuxt')
module.exports = {
component (on, config) {
on('dev-server:start', async (options) => {
let webpackConfig = await getWebpackConfig('modern', 'dev')
module.exports = {
component (on, config) {
on('dev-server:start', async (options) => {
let webpackConfig = await getWebpackConfig('modern', 'dev')
return startDevServer({
options,
webpackConfig,
return startDevServer({
options,
webpackConfig,
})
})
})
},
}
`,
ts: dedent`
import { defineConfig } from 'cypress'
import { startDevServer } from '@cypress/webpack-dev-server'
import { getWebpackConfig } from 'nuxt'
},
}
`,
ts: dedent`
import { defineConfig } from 'cypress'
export default defineConfig({
component (on, config) {
on('dev-server:start', async (options) => {
let webpackConfig = await getWebpackConfig('modern', 'dev')
export default defineConfig({
component: {
testFiles: "**/*cy-spec.tsx",
componentFolder: "src"
}
})
`,
},
cra: {
js: endent`
const { defineConfig } = require('cypress')
return startDevServer({
options,
webpackConfig,
})
})
},
})
`,
},
module.exports = defineConfig({
component: {
testFiles: "**/*cy-spec.{js,jsx,ts,tsx}",
componentFolder: "src"
}
})
`,
ts: endent`
import { defineConfig } from 'cypress'
export default defineConfig({
component: {
testFiles: "**/*cy-spec.{js,jsx,ts,tsx}",
componentFolder: "src"
}
})
`,
},
vuecli: {
js: endent`
const { defineConfig } = require('cypress')
module.exports = defineConfig({
component: {
testFiles: "**/*cy-spec.{js,jsx,ts,tsx}",
componentFolder: "src"
}
})
`,
ts: endent`
import { defineConfig } from 'cypress'
export default defineConfig({
component: {
testFiles: "**/*cy-spec.{js,jsx,ts,tsx}",
componentFolder: "src"
}
})
`,
},
}[opts.framework.type as string]
}
export const wizardGetComponentIndexHtml = (opts: Omit<GetCodeOptsCt, 'lang' | 'type'>) => {
const framework = opts.framework.type
let headModifier = ''
let bodyModifier = ''
if (framework === 'nextjs') {
headModifier += '<div id="__next_css__DO_NOT_USE__"></div>'
}
const previewHead = opts.storybookInfo?.files.find(({ name }) => name === 'preview-head.html')
if (previewHead) {
headModifier += previewHead.content
}
const previewBody = opts.storybookInfo?.files.find(({ name }) => name === 'preview-body.html')
if (previewBody) {
headModifier += previewBody.content
}
return getComponentTemplate({ headModifier, bodyModifier })
}
const getComponentTemplate = (opts: {headModifier: string, bodyModifier: string}) => {
// TODO: Properly indent additions and strip newline if none
return endent`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
${opts.headModifier}
</head>
<body>
${opts.bodyModifier}
<div id="__cy_root"></div>
</body>
</html>`
}
@@ -6,5 +6,6 @@ export * from './BrowserDataSource'
export * from './FileDataSource'
export * from './GitDataSource'
export * from './ProjectDataSource'
export * from './StorybookDataSource'
export * from './UtilDataSource'
export * from './WizardDataSource'
@@ -40,4 +40,7 @@ export const stubMutation: MaybeResolver<Mutation> = {
return ctx.app
},
generateSpecFromStory (source, args, ctx) {
return ctx.wizard
},
}
+22
View File
@@ -269,10 +269,16 @@ type Mutation {
path: String!
): App!
"""Create an Index HTML file for a new component testing project"""
appCreateComponentIndexHtml(template: String!): App
"""Create a Cypress config file for a new project"""
appCreateConfigFile(code: String!, configFilename: String!): App
clearActiveProject: Query!
"""Generate spec from Storybook story"""
generateSpecFromStory(storyPath: String!): Wizard!
"""
Initializes open_project global singleton to manager current project state
"""
@@ -521,6 +527,18 @@ enum SpecType {
integration
}
"""Storybook"""
type Storybook {
"""Whether this is the selected framework bundler"""
configured: Boolean
"""Most recent generated spec"""
generatedSpec: String
"""List of all Storybook stories"""
stories: [String!]!
}
"""The bundlers that we can use with Cypress"""
enum SupportedBundlers {
vite
@@ -574,7 +592,11 @@ type Wizard {
"""Configuration file based on bundler and framework of choice"""
sampleCode(lang: WizardCodeLanguage! = js): String
"""IndexHtml file based on bundler and framework of choice"""
sampleTemplate: String
step: WizardStep!
storybook: Storybook
"""
The testing type we are setting in the wizard, null if this has not been chosen
@@ -105,6 +105,32 @@ export const mutation = mutationType({
},
})
t.field('appCreateComponentIndexHtml', {
type: 'App',
args: {
template: nonNull('String'),
},
description: 'Create an Index HTML file for a new component testing project',
resolve: async (root, args, ctx) => {
await ctx.actions.project.createComponentIndexHtml(args.template)
return ctx.appData
},
})
t.nonNull.field('generateSpecFromStory', {
type: 'Wizard',
description: 'Generate spec from Storybook story',
args: {
storyPath: nonNull('String'),
},
async resolve (_root, args, ctx) {
await ctx.actions.storybook.generateSpecFromStory(args.storyPath)
return ctx.wizardData
},
})
t.field('navigationMenuSetItem', {
type: 'NavigationMenu',
description: 'Set the current navigation item',
@@ -0,0 +1,30 @@
import { objectType } from 'nexus'
export const Storybook = objectType({
name: 'Storybook',
description: 'Storybook',
definition (t) {
t.boolean('configured', {
description: 'Whether this is the selected framework bundler',
resolve: async (source, args, ctx) => !!(await ctx.storybook.storybookInfo),
})
t.nonNull.list.nonNull.string('stories', {
description: 'List of all Storybook stories',
resolve: async (source, args, ctx) => {
const storybook = await ctx.storybook.storybookInfo
if (!storybook) {
return []
}
return storybook.getStories(storybook.storybookRoot, storybook.storyGlobs)
},
})
t.string('generatedSpec', {
description: 'Most recent generated spec',
resolve: (source, args, ctx) => ctx.wizardData.generatedSpec?.relative ?? null,
})
},
})
@@ -5,6 +5,7 @@ import { WizardNpmPackage } from './gql-WizardNpmPackage'
import { arg, nonNull, objectType } from 'nexus'
import { BUNDLERS, FRONTEND_FRAMEWORKS, TESTING_TYPES } from '@packages/types'
import { TestingTypeEnum, WizardCodeLanguageEnum, WizardStepEnum } from '../enumTypes/gql-WizardEnums'
import { Storybook } from './gql-Storybook'
export const Wizard = objectType({
name: 'Wizard',
@@ -71,6 +72,11 @@ export const Wizard = objectType({
resolve: (source, args, ctx) => ctx.wizard.sampleCode(args.lang),
})
t.string('sampleTemplate', {
description: 'IndexHtml file based on bundler and framework of choice',
resolve: (source, args, ctx) => ctx.wizard.sampleTemplate(),
})
t.nonNull.field('step', {
type: WizardStepEnum,
resolve: (source) => source.currentStep,
@@ -91,6 +97,11 @@ export const Wizard = objectType({
description: 'The title of the page, given the current step of the wizard',
resolve: (source, args, ctx) => ctx.wizard.title ?? null,
})
t.field('storybook', {
type: Storybook,
resolve: (source, args, ctx) => ctx.storybook.storybookInfo,
})
},
sourceType: {
module: '@packages/data-context/src/data/coreDataShape',
@@ -10,6 +10,7 @@ export * from './gql-NavigationMenu'
export * from './gql-Project'
export * from './gql-Query'
export * from './gql-Spec'
export * from './gql-Storybook'
export * from './gql-TestingTypeInfo'
export * from './gql-Wizard'
export * from './gql-WizardBundler'
+18 -1
View File
@@ -73,7 +73,7 @@ import PrismJs from 'vue-prism-component'
import WizardLayout from './WizardLayout.vue'
import CopyButton from '@cy/components/CopyButton.vue'
import { languages } from '../utils/configFile'
import { ConfigFileFragment, ConfigFile_AppCreateConfigFileDocument } from '../generated/graphql'
import { ConfigFileFragment, ConfigFile_AppCreateConfigFileDocument, ConfigFile_AppCreateComponentIndexHtmlDocument } from '../generated/graphql'
import { useMutation } from '@urql/vue'
import { useI18n } from '@cy/i18n'
@@ -98,6 +98,7 @@ fragment SampleCode on Wizard {
canNavigateForward
sampleCodeJs: sampleCode(lang: js)
sampleCodeTs: sampleCode(lang: ts)
sampleTemplate
}
`
@@ -112,6 +113,17 @@ mutation ConfigFile_appCreateConfigFile($code: String!, $configFilename: String!
}
`
gql`
mutation ConfigFile_appCreateComponentIndexHtml($template: String!) {
appCreateComponentIndexHtml(template: $template) {
activeProject {
id
projectRoot
}
}
}
`
const props = defineProps<{
gql: ConfigFileFragment
}>()
@@ -133,6 +145,7 @@ import('prismjs/components/prism-typescript').then(() => {
})
const createConfigFile = useMutation(ConfigFile_AppCreateConfigFileDocument)
const createComponentIndexHtml = useMutation(ConfigFile_AppCreateComponentIndexHtmlDocument)
const code = computed(() => {
if (language.value === 'js') {
@@ -160,6 +173,10 @@ const createConfig = async () => {
code: code.value,
configFilename: `cypress.config.${language.value}`,
})
await createComponentIndexHtml.executeMutation({
template: props.gql.wizard.sampleTemplate as string,
})
}
</script>
+2
View File
@@ -24,3 +24,5 @@ export {
} from './config'
export * from './server'
export * from './storybook'
+16
View File
@@ -0,0 +1,16 @@
export interface StorybookFile {
name: string
absolute: string
relative: string
content: string
}
export interface StorybookInfo {
storybookRoot: string
files: StorybookFile[]
storyGlobs: string[]
getStories: (
storybookRoot: string,
storyGlobs: string[]
) => Promise<string[]>
}
+36 -1
View File
@@ -2081,7 +2081,7 @@
"@babel/parser" "^7.15.4"
"@babel/types" "^7.15.4"
"@babel/traverse@7.15.4", "@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.15.4", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.5", "@babel/traverse@^7.6.0", "@babel/traverse@^7.7.0", "@babel/traverse@^7.8.3", "@babel/traverse@^7.9.0":
"@babel/traverse@7.15.4", "@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.15.4", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.5", "@babel/traverse@^7.6.0", "@babel/traverse@^7.7.0", "@babel/traverse@^7.8.3", "@babel/traverse@^7.9.0":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.4.tgz#ff8510367a144bfbff552d9e18e28f3e2889c22d"
integrity sha512-W6lQD8l4rUbQR/vYgSuCAE75ADyyQvOpFVsvPPdkhf6lATXAsQIG9YdtOcu8BB1dZ0LKu+Zo3c1wEcbKeuhdlA==
@@ -7764,6 +7764,29 @@
"@storybook/core-client" "6.2.7"
"@storybook/core-server" "6.2.7"
"@storybook/csf-tools@^6.4.0-alpha.38":
version "6.4.0-beta.4"
resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-6.4.0-beta.4.tgz#87e7afd9050c4b09755106bc5cb3fc31e9a1b271"
integrity sha512-CctTaXCpmfKQHVXuAyXcHoUXjDYfXTbNGLZkQ3jnceGvf/oMqp7bqennpM7CnnHayk0sOnlppAwnQDABb2RCog==
dependencies:
"@babel/core" "^7.12.10"
"@babel/generator" "^7.12.11"
"@babel/parser" "^7.12.11"
"@babel/plugin-transform-react-jsx" "^7.12.12"
"@babel/preset-env" "^7.12.11"
"@babel/traverse" "^7.12.11"
"@babel/types" "^7.12.11"
"@mdx-js/mdx" "^1.6.22"
"@storybook/csf" "0.0.2--canary.6aca495.0"
core-js "^3.8.2"
fs-extra "^9.0.1"
global "^4.4.0"
js-string-escape "^1.0.1"
lodash "^4.17.20"
prettier "^2.2.1"
regenerator-runtime "^0.13.7"
ts-dedent "^2.0.0"
"@storybook/csf@0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.0.1.tgz#95901507dc02f0bc6f9ac8ee1983e2fc5bb98ce6"
@@ -7771,6 +7794,13 @@
dependencies:
lodash "^4.17.15"
"@storybook/csf@0.0.2--canary.6aca495.0":
version "0.0.2--canary.6aca495.0"
resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.0.2--canary.6aca495.0.tgz#1569819337b0414e6e5575680c96505eda803d0b"
integrity sha512-SRKP/zXfHQ/tAlqVAysXGyVrYvILwyDuhisXP6hpXW7BHej2BXVxR4LizA5AFpj99j066KbxdHKFMNmrncZEuw==
dependencies:
lodash "^4.17.15"
"@storybook/node-logger@6.2.7":
version "6.2.7"
resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-6.2.7.tgz#2329042e44eba1efcc6bb0689c8566a555d5b0ee"
@@ -33050,6 +33080,11 @@ prettier@^1.16.4, prettier@^1.18.2:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
prettier@^2.2.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c"
integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==
prettier@~2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5"