diff --git a/.eslintignore b/.eslintignore index 907b3c5f0a..52b2bc6fdd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,7 +6,7 @@ **/build **/cypress/fixtures **/dist -**/dist-test +**/dist-* **/node_modules **/support/fixtures/* !**/support/fixtures/projects diff --git a/.gitignore b/.gitignore index 15727e63ba..99b08987bc 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,9 @@ packages/example/app packages/example/build packages/example/cypress/integration +# from frontend-shared +packages/frontend-shared/cypress/e2e/.projects + # from server packages/server/.cy packages/server/.projects diff --git a/package.json b/package.json index cc79aff0a8..3159069cd8 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "check-ts": "gulp checkTs", "clean-deps": "find . -depth -name node_modules -type d -exec rm -rf {} \\;", "clean-untracked-files": "git clean -d -f", + "codegen": "yarn gulp codegen", "debug": "yarn gulp debug", "precypress:open": "yarn ensure-deps", "cypress:open": "cypress open --dev --global", @@ -117,6 +118,7 @@ "@types/react": "16.9.50", "@types/react-dom": "16.9.8", "@types/request-promise": "4.1.45", + "@types/rimraf": "^3.0.2", "@types/send": "^0.17.1", "@types/sinon-chai": "3.2.3", "@types/through2": "^2.0.36", diff --git a/packages/app/cypress.json b/packages/app/cypress.json index 116aee5068..2c12d46efc 100644 --- a/packages/app/cypress.json +++ b/packages/app/cypress.json @@ -1,4 +1,5 @@ { + "$schema": "../../cli/schema/cypress.schema.json", "projectId": "sehy69", "viewportWidth": 800, "viewportHeight": 850, diff --git a/packages/app/cypress/e2e/integration/basic.spec.ts b/packages/app/cypress/e2e/integration/basic.spec.ts deleted file mode 100644 index 92f43eb68d..0000000000 --- a/packages/app/cypress/e2e/integration/basic.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -let GQL_PORT -let SERVER_PORT - -describe('App', () => { - beforeEach(() => { - cy.withCtx(async (ctx) => { - await ctx.dispose() - await ctx.actions.project.setActiveProject(ctx.launchArgs.projectRoot) - ctx.actions.wizard.setTestingType('e2e') - await ctx.actions.project.initializeActiveProject({ - skipPluginIntializeForTesting: true, - }) - - await ctx.actions.project.launchProject('e2e', { - skipBrowserOpenForTest: true, - }) - - return [ - ctx.gqlServerPort, - ctx.appServerPort, - ] - }).then(([gqlPort, serverPort]) => { - GQL_PORT = gqlPort - SERVER_PORT = serverPort - }) - }) - - it('resolves the home page', () => { - cy.visit(`dist/index.html?serverPort=${SERVER_PORT}&gqlPort=${GQL_PORT}`) - cy.get('[href="#/runner"]').click() - cy.get('[href="#/settings"]').click() - }) - - it('resolves the home page, with a different server port?', () => { - cy.visit(`dist/index.html?serverPort=${SERVER_PORT}&gqlPort=${GQL_PORT}`) - cy.get('[href="#/runner"]').click() - cy.get('[href="#/settings"]').click() - }) -}) diff --git a/packages/app/cypress/e2e/integration/files.spec.ts b/packages/app/cypress/e2e/integration/files.spec.ts new file mode 100644 index 0000000000..bd64ac70cc --- /dev/null +++ b/packages/app/cypress/e2e/integration/files.spec.ts @@ -0,0 +1,13 @@ +describe('App', () => { + beforeEach(() => { + cy.setupE2E('component-tests') + cy.initializeApp() + }) + + it('resolves the home page', () => { + cy.visitApp() + cy.wait(1000) + cy.get('[href="#/runner"]').click() + cy.get('[href="#/settings"]').click() + }) +}) diff --git a/packages/app/package.json b/packages/app/package.json index 49df2ddcba..58de561c6e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -9,8 +9,9 @@ "clean-deps": "rimraf node_modules", "test": "echo 'ok'", "cypress:launch": "cross-env TZ=America/New_York node ../../scripts/cypress open --project ${PWD}", - "cypress:open": "yarn gulp cyOpenAppE2E", - "cypress:run:e2e": "yarn gulp cyRunAppE2E", + "cypress:open": "cross-env TZ=America/New_York node ../../scripts/cypress open --project ${PWD}", + "cypress:run:ct": "cross-env TZ=America/New_York node ../../scripts/cypress run-ct --project ${PWD}", + "cypress:run:e2e": "cross-env TZ=America/New_York node ../../scripts/cypress run --project ${PWD}", "debug": "gulp debug --project ${PWD}", "dev": "gulp dev --project ${PWD}", "start": "echo \"run 'yarn dev' from the root\" && exit 1", diff --git a/packages/data-context/src/DataActions.ts b/packages/data-context/src/DataActions.ts index c074e9251e..f4b092d3e4 100644 --- a/packages/data-context/src/DataActions.ts +++ b/packages/data-context/src/DataActions.ts @@ -1,5 +1,5 @@ import type { DataContext } from '.' -import { AppActions, ProjectActions, StorybookActions, WizardActions } from './actions' +import { AppActions, FileActions, ProjectActions, StorybookActions, WizardActions } from './actions' import { AuthActions } from './actions/AuthActions' import { DevActions } from './actions/DevActions' import { cached } from './util' @@ -7,6 +7,11 @@ import { cached } from './util' export class DataActions { constructor (private ctx: DataContext) {} + @cached + get file () { + return new FileActions(this.ctx) + } + @cached get dev () { return new DevActions(this.ctx) diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index fb101ced3c..78fdaf863f 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -1,4 +1,5 @@ import type { LaunchArgs, OpenProjectLaunchOptions, PlatformName } from '@packages/types' +import path from 'path' import type { AppApiShape, ProjectApiShape } from './actions' import type { NexusGenAbstractTypeMembers } from '@packages/graphql/src/gen/nxs.gen' import type { AuthApiShape } from './actions/AuthActions' @@ -45,6 +46,11 @@ export class DataContext extends DataContextShell { return fsExtra } + @cached + get path () { + return path + } + constructor (private config: DataContextConfig) { super(config) this._coreData = config.coreData ?? makeCoreData() @@ -193,7 +199,9 @@ export class DataContext extends DataContextShell { console.error(e) } - async dispose () { + async destroy () { + super.destroy() + return Promise.all([ this.util.disposeLoaders(), this.actions.project.clearActiveProject(), diff --git a/packages/data-context/src/DataContextShell.ts b/packages/data-context/src/DataContextShell.ts index 9563c4f7bc..97692c675c 100644 --- a/packages/data-context/src/DataContextShell.ts +++ b/packages/data-context/src/DataContextShell.ts @@ -1,4 +1,6 @@ import { EventEmitter } from 'events' +import type { Server } from 'http' +import type { AddressInfo } from 'net' import { DataEmitterActions } from './actions/DataEmitterActions' import { cached } from './util/cached' @@ -9,6 +11,7 @@ export interface DataContextShellConfig { // Used in places where we have to create a "shell" data context, // for non-unified parts of the codebase export class DataContextShell { + private _gqlServer?: Server private _appServerPort: number | undefined private _gqlServerPort: number | undefined @@ -18,8 +21,9 @@ export class DataContextShell { this._appServerPort = port } - setGqlServerPort (port: number | undefined) { - this._gqlServerPort = port + setGqlServer (srv: Server) { + this._gqlServer = srv + this._gqlServerPort = (srv.address() as AddressInfo).port } get appServerPort () { @@ -40,4 +44,8 @@ export class DataContextShell { busApi: this.shellConfig.rootBus, } } + + destroy () { + this._gqlServer?.close() + } } diff --git a/packages/data-context/src/actions/FileActions.ts b/packages/data-context/src/actions/FileActions.ts index acc570c1e9..59a1ad4e09 100644 --- a/packages/data-context/src/actions/FileActions.ts +++ b/packages/data-context/src/actions/FileActions.ts @@ -1,5 +1,18 @@ +import path from 'path' + import type { DataContext } from '..' export class FileActions { constructor (private ctx: DataContext) {} + + async writeFileInProject (relativePath: string, data: any) { + if (!this.ctx.activeProject) { + throw new Error(`Cannot write file in project without active project`) + } + + await this.ctx.fs.writeFile( + path.join(this.ctx.activeProject?.projectRoot, relativePath), + data, + ) + } } diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index cbeabbdb1a..bb8e17d245 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -30,9 +30,14 @@ export class ProjectActions { } async clearActiveProject () { - this.ctx.appData.activeProject = null + await this.api.closeActiveProject() - return this.api.closeActiveProject() + // TODO(tim): Improve general state management w/ immutability (immer) & updater fn + this.ctx.coreData.app.isInGlobalMode = true + this.ctx.coreData.app.activeProject = null + this.ctx.coreData.app.activeTestingType = null + this.ctx.coreData.wizard.history = ['welcome'] + this.ctx.coreData.wizard.currentStep = 'welcome' } private get projects () { diff --git a/packages/data-context/src/sources/SettingsDataSource.ts b/packages/data-context/src/sources/SettingsDataSource.ts new file mode 100644 index 0000000000..d123d22fb0 --- /dev/null +++ b/packages/data-context/src/sources/SettingsDataSource.ts @@ -0,0 +1,9 @@ +import type { DataContext } from '..' + +export class SettingsDataSource { + constructor (private ctx: DataContext) {} + + readSettingsForProject (projectRoot: string) { + this.ctx.util.assertAbsolute(projectRoot) + } +} diff --git a/packages/data-context/src/sources/UtilDataSource.ts b/packages/data-context/src/sources/UtilDataSource.ts index bee192d53c..2a1603cca8 100644 --- a/packages/data-context/src/sources/UtilDataSource.ts +++ b/packages/data-context/src/sources/UtilDataSource.ts @@ -18,6 +18,12 @@ export class UtilDataSource { return vals.map((v) => v.status === 'fulfilled' ? v.value : this.ensureError(v.reason)) } + assertAbsolute (val: string) { + if (!this.ctx.path.isAbsolute(val)) { + throw new Error(`Expected ${val} to be an absolute path`) + } + } + ensureError (val: any): Error { return val instanceof Error ? val : new Error(val) } diff --git a/packages/data-context/src/sources/index.ts b/packages/data-context/src/sources/index.ts index 76c482a5ae..5557beb65a 100644 --- a/packages/data-context/src/sources/index.ts +++ b/packages/data-context/src/sources/index.ts @@ -6,6 +6,7 @@ export * from './BrowserDataSource' export * from './FileDataSource' export * from './GitDataSource' export * from './ProjectDataSource' +export * from './SettingsDataSource' export * from './StorybookDataSource' export * from './UtilDataSource' export * from './WizardDataSource' diff --git a/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts b/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts index 45774f319c..e1ed7239e5 100644 --- a/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts +++ b/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts @@ -1,37 +1,92 @@ import type { DataContext } from '@packages/data-context' +import * as inspector from 'inspector' +import sinonChai from '@cypress/sinon-chai' +import sinon from 'sinon' +import rimraf from 'rimraf' +import util from 'util' + +// require'd so we don't conflict with globals loaded in @packages/types +const chai = require('chai') +const chaiAsPromised = require('chai-as-promised') +const chaiSubset = require('chai-subset') +const { expect } = chai + +chai.use(chaiAsPromised) +chai.use(chaiSubset) +chai.use(sinonChai) + +import path from 'path' +import type { WithCtxInjected, WithCtxOptions } from './support/e2eSupport' +import { e2eProjectDirs } from './support/e2eProjectDirs' export async function e2ePluginSetup (projectRoot: string, on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) { + process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF = 'true' // require'd so we don't import the types from @packages/server which would // pollute strict type checking const { runInternalServer } = require('@packages/server/lib/modes/internal-server') + const Fixtures = require('../../../server/test/support/helpers/fixtures') + const tmpDir = path.join(__dirname, '.projects') - process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF = 'true' - const { serverPortPromise, ctx } = runInternalServer({ - projectRoot, - }) as {ctx: DataContext, serverPortPromise: Promise} + await util.promisify(rimraf)(tmpDir) + + Fixtures.setTmpDir(tmpDir) + + interface WithCtxObj { + fn: string + options: WithCtxOptions + activeTestId: string + } + + let ctx: DataContext + let serverPortPromise: Promise + let currentTestId: string | undefined + let testState: Record = {} on('task', { - async withCtx (fnString: string) { - await serverPortPromise + setupE2E (projectName: string) { + Fixtures.scaffoldProject(projectName) - return new Function('ctx', `return (${fnString})(ctx)`).call(undefined, ctx) + return null }, - async resetCtxState () { - return ctx.dispose() - }, - async visitLaunchpad () { + async withCtx (obj: WithCtxObj) { + // Ensure we spin up a completely isolated server/state for each test + if (obj.activeTestId !== currentTestId) { + ctx?.destroy() + currentTestId = obj.activeTestId + testState = {}; + ({ serverPortPromise, ctx } = runInternalServer({ + projectRoot: null, + }) as {ctx: DataContext, serverPortPromise: Promise}) - }, - async visitApp () { + await serverPortPromise + } - }, - getGraphQLPort () { - return serverPortPromise - }, - getAppServerPort () { - return ctx.appServerPort ?? null + const options: WithCtxInjected = { + ...obj.options, + testState, + require, + process, + projectDir (projectName) { + if (!e2eProjectDirs.includes(projectName)) { + throw new Error(`${projectName} is not a fixture project`) + } + + return path.join(tmpDir, projectName) + }, + } + + const val = await Promise.resolve(new Function('ctx', 'options', 'chai', 'expect', 'sinon', ` + return (${obj.fn})(ctx, options, chai, expect, sinon) + `).call(undefined, ctx, options, chai, expect, sinon)) + + return val || null }, }) - return config + return { + ...config, + env: { + e2e_isDebugging: Boolean(inspector.url()), + }, + } } diff --git a/packages/frontend-shared/cypress/e2e/support/e2eProjectDirs.ts b/packages/frontend-shared/cypress/e2e/support/e2eProjectDirs.ts new file mode 100644 index 0000000000..9a1d9ea8cf --- /dev/null +++ b/packages/frontend-shared/cypress/e2e/support/e2eProjectDirs.ts @@ -0,0 +1,87 @@ +/* eslint-disable */ +// Auto-generated by gulpE2ETestScaffold.ts +export const e2eProjectDirs = [ + 'browser-extensions', + 'busted-support-file', + 'chrome-browser-preferences', + 'component-tests', + 'config-with-custom-file-js', + 'config-with-custom-file-ts', + 'config-with-invalid-browser', + 'config-with-invalid-viewport', + 'config-with-js', + 'config-with-short-timeout', + 'config-with-ts', + 'cookies', + 'default-layout', + 'downloads', + 'e2e', + 'empty-folders', + 'failures', + 'firefox-memory', + 'fixture-subfolder-of-integration', + 'folder-same-as-fixture', + 'hooks-after-rerun', + 'ids', + 'integration-outside-project-root', + 'issue-8111-iframe-input', + 'max-listeners', + 'multiple-task-registrations', + 'no-scaffolding', + 'no-server', + 'non-existent-spec', + 'non-proxied', + 'odd-directory-name', + 'plugin-after-screenshot', + 'plugin-after-spec-deletes-video', + 'plugin-before-browser-launch-deprecation', + 'plugin-browser', + 'plugin-config', + 'plugin-config-version', + 'plugin-empty', + 'plugin-event-deprecated', + 'plugin-extension', + 'plugin-filter-browsers', + 'plugin-retries', + 'plugin-returns-bad-config', + 'plugin-returns-empty-browsers-list', + 'plugin-returns-invalid-browser', + 'plugin-run-event-throws', + 'plugin-run-events', + 'plugin-validation-error', + 'plugins-absolute-path', + 'plugins-async-error', + 'plugins-root-async-error', + 'pristine', + 'read-only-project-root', + 'record', + 'remote-debugging-disconnect', + 'remote-debugging-port-removed', + 'retries-2', + 'same-fixtures-integration-folders', + 'screen-size', + 'server', + 'shadow-dom-global-inclusion', + 'studio', + 'studio-no-source-maps', + 'system-node', + 'task-not-registered', + 'todos', + 'ts-installed', + 'ts-proj', + 'ts-proj-custom-names', + 'ts-proj-esmoduleinterop-true', + 'ts-proj-tsconfig-in-plugins', + 'ts-proj-with-module-esnext', + 'ts-proj-with-paths', + 'uncaught-support-file', + 'unify-onboarding', + 'unify-plugin-errors', + 'various-file-types', + 'webpack-preprocessor', + 'webpack-preprocessor-awesome-typescript-loader', + 'webpack-preprocessor-ts-loader', + 'webpack-preprocessor-ts-loader-compiler-options', + 'working-preprocessor', + 'yarn-v2-pnp' +] as const diff --git a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts index 3556ea9cff..2649e0904b 100644 --- a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts +++ b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts @@ -1,17 +1,125 @@ +import '@testing-library/cypress/add-commands' import type { DataContext } from '@packages/data-context' +import { e2eProjectDirs } from './e2eProjectDirs' -const SIXTY_SECONDS = 60 * 1000 +const NO_TIMEOUT = 1000 * 1000 +const FOUR_SECONDS = 4 * 1000 + +export type ProjectFixture = typeof e2eProjectDirs[number] + +export interface WithCtxOptions extends Cypress.Loggable, Cypress.Timeoutable { + projectName?: ProjectFixture + [key: string]: any +} + +export interface WithCtxInjected extends WithCtxOptions { + require: typeof require + process: typeof process + testState: Record + projectDir(projectName: ProjectFixture): string +} declare global { namespace Cypress { interface Chainable { - withCtx(fn: (ctx: DataContext) => any): Chainable + /** + * Calls a function block with the "ctx" object from the server, + * and an object containing any options passed into the server context + * and some helper properties: + * + * + * You cannot access any variables outside of the function scope, + * however we do provide expect, chai, sinon + */ + withCtx: typeof withCtx + /** + * Takes the name of a "system" test directory, and mounts the project within open mode + */ + setupE2E: typeof setupE2E + initializeApp: typeof initializeApp + visitApp(href?: string): Chainable + visitLaunchpad(href?: string): Chainable } } } -Cypress.Commands.add('withCtx', (fn) => { - const _log = Cypress.log({ +beforeEach(() => { + // Reset the ports so we know we need to call "setupE2E" before each test + Cypress.env('e2e_serverPort', undefined) + Cypress.env('e2e_gqlPort', undefined) +}) + +// function setup + +function setupE2E (projectName?: ProjectFixture) { + if (projectName && !e2eProjectDirs.includes(projectName)) { + throw new Error(`Unknown project ${projectName}`) + } + + if (projectName) { + cy.task('setupE2E', projectName, { log: false }) + } + + return cy.withCtx(async (ctx, o) => { + if (o.projectName) { + await ctx.actions.project.setActiveProject(o.projectDir(o.projectName)) + } + + return [ + ctx.gqlServerPort, + ctx.appServerPort, + ] + }, { projectName, log: false }).then(([gqlPort, serverPort]) => { + Cypress.env('e2e_gqlPort', gqlPort) + Cypress.env('e2e_serverPort', serverPort) + }) +} + +function initializeApp (mode: 'component' | 'e2e' = 'e2e') { + return cy.withCtx(async (ctx, o) => { + ctx.actions.wizard.setTestingType(o.mode) + await ctx.actions.project.initializeActiveProject({ + skipPluginIntializeForTesting: true, + }) + + await ctx.actions.project.launchProject(o.mode, { + skipBrowserOpenForTest: true, + }) + + return ctx.appServerPort + }, { log: false, mode }).then((serverPort) => { + Cypress.env('e2e_serverPort', serverPort) + }) +} + +function visitApp () { + const { e2e_serverPort, e2e_gqlPort } = Cypress.env() + + if (!e2e_gqlPort) { + throw new Error(`Missing gqlPort - did you forget to call cy.setupE2E(...) ?`) + } + + if (!e2e_serverPort) { + throw new Error(`Missing serverPort - did you forget to call cy.initializeApp(...) ?`) + } + + return cy.visit(`dist-app/index.html?gqlPort=${e2e_gqlPort}&serverPort=${e2e_serverPort}`) +} + +function visitLaunchpad (hash?: string) { + const { e2e_gqlPort } = Cypress.env() + + if (!e2e_gqlPort) { + throw new Error(`Missing gqlPort - did you forget to call cy.setupE2E(...) ?`) + } + + cy.visit(`dist-launchpad/index.html?gqlPort=${e2e_gqlPort}`) +} + +const pageLoadId = `uid${Math.random()}` + +function withCtx> (fn: (ctx: DataContext, o: T & WithCtxInjected) => any, opts: T = {} as T): Cypress.Chainable { + const _log = opts.log === false ? { end () {} } : Cypress.log({ name: 'withCtx', message: '(view in console)', consoleProps () { @@ -21,7 +129,20 @@ Cypress.Commands.add('withCtx', (fn) => { }, }) - cy.task('withCtx', fn.toString(), { timeout: SIXTY_SECONDS, log: false }).then(() => { + const { log, timeout, ...rest } = opts + + return cy.task('withCtx', { + fn: fn.toString(), + options: rest, + // @ts-expect-error + activeTestId: `${pageLoadId}-${Cypress.mocha.getRunner().test.id ?? Cypress.currentTest.title}`, + }, { timeout: timeout ?? Cypress.env('e2e_isDebugging') ? NO_TIMEOUT : FOUR_SECONDS, log }).then(() => { _log.end() }) -}) +} + +Cypress.Commands.add('visitApp', visitApp) +Cypress.Commands.add('visitLaunchpad', visitLaunchpad) +Cypress.Commands.add('initializeApp', initializeApp) +Cypress.Commands.add('setupE2E', setupE2E) +Cypress.Commands.add('withCtx', withCtx) diff --git a/packages/frontend-shared/src/graphql/urqlClient.ts b/packages/frontend-shared/src/graphql/urqlClient.ts index dac4159f31..74838258f2 100644 --- a/packages/frontend-shared/src/graphql/urqlClient.ts +++ b/packages/frontend-shared/src/graphql/urqlClient.ts @@ -12,6 +12,7 @@ import { client } from '@packages/socket/lib/browser' import { cacheExchange as graphcacheExchange } from '@urql/exchange-graphcache' import { pubSubExchange } from './urqlExchangePubsub' +import { namedRouteExchange } from './urqlExchangeNamedRoute' const GQL_PORT_MATCH = /gqlPort=(\d+)/.exec(window.location.search) const SERVER_PORT_MATCH = /serverPort=(\d+)/.exec(window.location.search) @@ -69,6 +70,7 @@ export function makeUrqlClient (target: 'launchpad' | 'app'): Client { }), // https://formidable.com/open-source/urql/docs/graphcache/errors/ makeCacheExchange(), + namedRouteExchange, // TODO(tim): add this when we want to use the socket as the GraphQL // transport layer for all operations // target === 'launchpad' ? fetchExchange : socketExchange(io), diff --git a/packages/frontend-shared/src/graphql/urqlExchangeNamedRoute.ts b/packages/frontend-shared/src/graphql/urqlExchangeNamedRoute.ts new file mode 100644 index 0000000000..87e5a9cf06 --- /dev/null +++ b/packages/frontend-shared/src/graphql/urqlExchangeNamedRoute.ts @@ -0,0 +1,19 @@ +import { Exchange, getOperationName } from '@urql/core' +import { map, pipe } from 'wonka' + +export const namedRouteExchange: Exchange = ({ client, forward }) => { + return (ops$) => { + return forward(pipe( + ops$, + map((o) => { + return { + ...o, + context: { + ...o.context, + url: `${o.context.url}/${o.kind}-${getOperationName(o.query)}`, + }, + } + }), + )) + } +} diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 37fc2f6bda..c58b80562d 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -56,8 +56,8 @@ export const mutation = mutationType({ t.nonNull.field('clearActiveProject', { type: 'Query', - resolve: (root, args, ctx) => { - ctx.actions.project.clearActiveProject() + resolve: async (root, args, ctx) => { + await ctx.actions.project.clearActiveProject() return {} }, diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Project.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Project.ts index 1bb5c1fb94..fe54881221 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Project.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Project.ts @@ -78,4 +78,8 @@ export const Project = objectType({ resolve: (source, args, ctx) => ctx.storybook.loadStorybookInfo(), }) }, + sourceType: { + module: __dirname, + export: 'ProjectShape', + }, }) diff --git a/packages/graphql/src/server.ts b/packages/graphql/src/server.ts index 4ea925d4c2..dc1d85444e 100644 --- a/packages/graphql/src/server.ts +++ b/packages/graphql/src/server.ts @@ -9,7 +9,7 @@ import { parse } from 'graphql' const SHOW_GRAPHIQL = process.env.CYPRESS_INTERNAL_ENV !== 'production' export function addGraphQLHTTP (app: ReturnType, context: DataContext) { - app.use('/graphql', graphqlHTTP((req, res, params) => { + app.use('/graphql/:operationName?', graphqlHTTP((req, res, params) => { const ctx = SHOW_GRAPHIQL ? maybeProxyContext(params, context) : context return { diff --git a/packages/launchpad/cypress.json b/packages/launchpad/cypress.json index d3153dfd08..ee408115b8 100644 --- a/packages/launchpad/cypress.json +++ b/packages/launchpad/cypress.json @@ -1,4 +1,5 @@ { + "$schema": "../../cli/schema/cypress.schema.json", "projectId": "sehy69", "viewportWidth": 800, "viewportHeight": 850, @@ -19,6 +20,8 @@ "pluginsFile": "cypress/component/plugins/index.js" }, "e2e": { - "supportFile": false + "supportFile": "cypress/e2e/support/e2eSupport.ts", + "integrationFolder": "cypress/e2e/integration", + "pluginsFile": "cypress/e2e/plugins/index.ts" } } diff --git a/packages/launchpad/cypress/e2e/integration/onboarding-flow.spec.ts b/packages/launchpad/cypress/e2e/integration/onboarding-flow.spec.ts new file mode 100644 index 0000000000..8462d3c287 --- /dev/null +++ b/packages/launchpad/cypress/e2e/integration/onboarding-flow.spec.ts @@ -0,0 +1,21 @@ +describe('Onboarding Flow', () => { + beforeEach(() => { + cy.setupE2E('unify-onboarding') + }) + + it('can scaffold a project in e2e mode', () => { + cy.visitLaunchpad() + cy.get('[data-cy-testingType=component]').click() + cy.get('[data-cy=select-framework]').click() + cy.get('[data-cy-framework=vue]').click() + cy.get('[data-cy=select-framework]').should('contain', 'Vue') + cy.get('[data-cy=select-bundler]').click() + cy.get('[data-cy-bundler=webpack]').click() + cy.get('[data-cy=select-bundler]').should('contain', 'Webpack') + cy.reload() + cy.get('[data-cy=select-framework]').should('contain', 'Vue') + cy.get('[data-cy=select-bundler]').should('contain', 'Webpack') + cy.findByText('Next Step').click() + cy.get('h1').should('contain', 'Dependencies') + }) +}) diff --git a/packages/launchpad/cypress/e2e/integration/open-mode.spec.ts b/packages/launchpad/cypress/e2e/integration/open-mode.spec.ts new file mode 100644 index 0000000000..4233cf31ba --- /dev/null +++ b/packages/launchpad/cypress/e2e/integration/open-mode.spec.ts @@ -0,0 +1,20 @@ +describe('Launchpad: Open Mode', () => { + beforeEach(() => { + cy.setupE2E() + cy.visitLaunchpad() + }) + + it('Shows the open page', () => { + cy.get('h1').should('contain', 'Cypress') + }) + + it('allows adding a project', () => { + cy.withCtx(async (ctx, o) => { + await ctx.actions.project.setActiveProject(o.projectDir('todos')) + ctx.emitter.toLaunchpad() + }) + + cy.get('h1').should('contain', 'Welcome to Cypress') + cy.findByText('Choose which method of testing you would like to set up first.') + }) +}) diff --git a/packages/launchpad/cypress/e2e/integration/plugin-error-handling.spec.ts b/packages/launchpad/cypress/e2e/integration/plugin-error-handling.spec.ts new file mode 100644 index 0000000000..33ed907761 --- /dev/null +++ b/packages/launchpad/cypress/e2e/integration/plugin-error-handling.spec.ts @@ -0,0 +1,16 @@ +describe('Plugin error handling', () => { + it('it handles a plugin error', () => { + cy.setupE2E('unify-plugin-errors') + cy.visitLaunchpad() + // TODO(alejandro): use this to test against error flow + cy.get('[data-cy-testingType=e2e]').click() + cy.wait(2000) + + cy.withCtx((ctx) => { + ctx.actions.file.writeFileInProject('cypress/plugins/index.js', `module.exports = (on, config) => {}`) + }) + + cy.reload() + cy.wait(2000) + }) +}) diff --git a/packages/launchpad/cypress/plugins/index.ts b/packages/launchpad/cypress/e2e/plugins/index.ts similarity index 90% rename from packages/launchpad/cypress/plugins/index.ts rename to packages/launchpad/cypress/e2e/plugins/index.ts index 1fae36a99d..619fcac0d0 100644 --- a/packages/launchpad/cypress/plugins/index.ts +++ b/packages/launchpad/cypress/e2e/plugins/index.ts @@ -1,5 +1,5 @@ /// -const { monorepoPaths } = require('../../../../scripts/gulp/monorepoPaths') +const { monorepoPaths } = require('../../../../../scripts/gulp/monorepoPaths') import { e2ePluginSetup } from '@packages/frontend-shared/cypress/e2e/e2ePluginSetup' // *********************************************************** diff --git a/packages/launchpad/cypress/e2e/support/e2eSupport.ts b/packages/launchpad/cypress/e2e/support/e2eSupport.ts new file mode 100644 index 0000000000..4b448145df --- /dev/null +++ b/packages/launchpad/cypress/e2e/support/e2eSupport.ts @@ -0,0 +1,2 @@ +/// +require('../../../../frontend-shared/cypress/e2e/support/e2eSupport') diff --git a/packages/launchpad/cypress/integration/basic.spec.ts b/packages/launchpad/cypress/integration/basic.spec.ts deleted file mode 100644 index 6bf7c40a17..0000000000 --- a/packages/launchpad/cypress/integration/basic.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -let GQL_PORT - -describe('Launchpad', () => { - before(() => { - cy.task('getGraphQLPort').then((port) => { - GQL_PORT = port - }) - }) - - it('resolves the home page', () => { - cy.visit(`dist/index.html?gqlPort=${GQL_PORT}`) - cy.get('h1').should('contain', 'Welcome') - }) -}) diff --git a/packages/launchpad/package.json b/packages/launchpad/package.json index e5e3b09fbf..6822c06e17 100644 --- a/packages/launchpad/package.json +++ b/packages/launchpad/package.json @@ -10,9 +10,9 @@ "test": "yarn cypress:run:ct && yarn types", "windi": "yarn windicss-analysis", "cypress:launch": "cross-env TZ=America/New_York node ../../scripts/cypress open --project ${PWD}", - "cypress:open": "yarn gulp cyOpenLaunchpadE2E", + "cypress:open": "cross-env TZ=America/New_York node ../../scripts/cypress open --project ${PWD}", "cypress:run:ct": "cross-env TZ=America/New_York node ../../scripts/cypress run-ct --project ${PWD}", - "cypress:run:e2e": "yarn gulp cyRunLaunchpadE2E", + "cypress:run:e2e": "cross-env TZ=America/New_York node ../../scripts/cypress run --project ${PWD}", "dev": "yarn gulp dev --project ${PWD}", "start": "echo 'run yarn dev from the root' && exit 1", "watch": "echo 'run yarn dev from the root' && exit 1" diff --git a/packages/launchpad/src/components/select/SelectBundler.vue b/packages/launchpad/src/components/select/SelectBundler.vue index 12254eb9c8..936a9141e6 100644 --- a/packages/launchpad/src/components/select/SelectBundler.vue +++ b/packages/launchpad/src/components/select/SelectBundler.vue @@ -21,6 +21,7 @@ w-full focus:border-indigo-600 focus:outline-transparent " + data-cy="select-bundler" :class="disabledClass + (isOpen ? ' border-indigo-600' : ' border-gray-200') + (props.disabled ? ' bg-gray-100 text-gray-800' : '')" @@ -68,6 +69,7 @@ :key="opt.type" focus="1" class="cursor-pointer flex items-center py-1 px-2 hover:bg-gray-10" + :data-cy-bundler="opt.type" @click="selectOption(opt.type)" > { - if (err) { - dfd.reject() - } - - dfd.resolve() - }) - - graphqlServer = undefined - - dfd.promise -} - export async function makeGraphQLServer (ctx: DataContext) { const dfd = pDefer() const app = express() @@ -39,7 +16,7 @@ export async function makeGraphQLServer (ctx: DataContext) { // it's not jammed into the projects addGraphQLHTTP(app, ctx) - const srv = graphqlServer = app.listen(() => { + const srv = app.listen(() => { const port = (srv.address() as AddressInfo).port const endpoint = `http://localhost:${port}/graphql` @@ -51,6 +28,8 @@ export async function makeGraphQLServer (ctx: DataContext) { ctx.debug(`GraphQL Server at ${endpoint}`) + ctx.setGqlServer(srv) + dfd.resolve(port) }) diff --git a/packages/server/lib/modes/internal-server.ts b/packages/server/lib/modes/internal-server.ts index 4abefb0565..5dee56af5a 100644 --- a/packages/server/lib/modes/internal-server.ts +++ b/packages/server/lib/modes/internal-server.ts @@ -23,9 +23,5 @@ export function runInternalServer (options) { const serverPortPromise = makeGraphQLServer(ctx) - serverPortPromise.then((port) => { - ctx.setGqlServerPort(port) - }) - return { ctx, bus, serverPortPromise } } diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index b0569ffa4f..c9371e947d 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -16,7 +16,7 @@ import type { LaunchOpts, LaunchArgs, OpenProjectLaunchOptions, FoundBrowser } f import { fs } from './util/fs' import path from 'path' import os from 'os' -import { closeGraphQLServer } from './gui/makeGraphQLServer' +import type { DataContextShell } from '@packages/data-context/src/DataContextShell' const debug = Debug('cypress:server:open_project') @@ -408,12 +408,15 @@ export class OpenProject { this.stopSpecsWatcher() return Promise.all([ - closeGraphQLServer(), + this._ctx?.destroy(), this.closeOpenProjectAndBrowsers(), ]).then(() => null) } + _ctx?: DataContextShell + async create (path: string, args: LaunchArgs, options: OpenProjectLaunchOptions, browsers: FoundBrowser[] = []) { + this._ctx = options.ctx debug('open_project create %s', path) _.defaults(options, { diff --git a/packages/server/lib/util/app_data.js b/packages/server/lib/util/app_data.js index 402f058625..b0d84e3813 100644 --- a/packages/server/lib/util/app_data.js +++ b/packages/server/lib/util/app_data.js @@ -94,10 +94,6 @@ module.exports = { // allow overriding the app_data folder let folder = env.CYPRESS_KONFIG_ENV || env.CYPRESS_INTERNAL_ENV - if (env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { - folder = `${folder}-e2e-test` - } - const p = path.join(ELECTRON_APP_DATA_PATH, 'cy', folder, ...paths) log('path: %s', p) diff --git a/packages/launchpad/cypress/integration/navigating-back-to-global-mode.ts b/packages/server/test/support/fixtures/projects/unify-onboarding/index.html similarity index 100% rename from packages/launchpad/cypress/integration/navigating-back-to-global-mode.ts rename to packages/server/test/support/fixtures/projects/unify-onboarding/index.html diff --git a/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress.json b/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress/integration/spec.js b/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress/integration/spec.js new file mode 100644 index 0000000000..8150675b62 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress/integration/spec.js @@ -0,0 +1,3 @@ +describe('unify-plugin-errors', () => { + it('ok', () => {}) +}) diff --git a/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress/plugins/index.js b/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress/plugins/index.js new file mode 100644 index 0000000000..71459cb7b9 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/unify-plugin-errors/cypress/plugins/index.js @@ -0,0 +1,5 @@ +module.exports = async function () { + await new Promise((resolve) => setTimeout(resolve, 1000)) + + throw new Error('Error Loading Plugin!!!') +} diff --git a/packages/server/test/support/helpers/fixtures.js b/packages/server/test/support/helpers/fixtures.js index 857f2b5a8d..0a23c3897b 100644 --- a/packages/server/test/support/helpers/fixtures.js +++ b/packages/server/test/support/helpers/fixtures.js @@ -4,7 +4,7 @@ const chokidar = require('chokidar') const root = path.join(__dirname, '..', '..', '..') const projects = path.join(root, 'test', 'support', 'fixtures', 'projects') -const tmpDir = path.join(root, '.projects') +let tmpDir = path.join(root, '.projects') // copy contents instead of deleting+creating new file, which can cause // filewatchers to lose track of toFile. @@ -22,6 +22,14 @@ const copyContents = (fromFile, toFile) => { } module.exports = { + setTmpDir (dir) { + tmpDir = dir + }, + + scaffoldProject (project) { + return fs.copySync(path.join(projects, project), path.join(tmpDir, project)) + }, + // copies all of the project fixtures // to the tmpDir .projects in the root scaffold () { diff --git a/scripts/gulp/gulpfile.ts b/scripts/gulp/gulpfile.ts index 59d7856076..b692becd39 100644 --- a/scripts/gulp/gulpfile.ts +++ b/scripts/gulp/gulpfile.ts @@ -11,7 +11,7 @@ import gulp from 'gulp' import { autobarrelWatcher } from './tasks/gulpAutobarrel' import { startCypressWatch, openCypressLaunchpad, openCypressApp, runCypressLaunchpad, wrapRunWithExit, runCypressApp, killExistingCypress } from './tasks/gulpCypress' import { graphqlCodegen, graphqlCodegenWatch, nexusCodegen, nexusCodegenWatch, generateFrontendSchema, syncRemoteGraphQL } from './tasks/gulpGraphql' -import { viteApp, viteCleanApp, viteCleanLaunchpad, viteLaunchpad, viteBuildApp, viteBuildAndWatchApp, viteBuildLaunchpad, viteBuildAndWatchLaunchpad } from './tasks/gulpVite' +import { viteApp, viteCleanApp, viteCleanLaunchpad, viteLaunchpad, viteBuildApp, viteBuildAndWatchApp, viteBuildLaunchpad, viteBuildAndWatchLaunchpad, symlinkViteProjects } from './tasks/gulpVite' import { checkTs } from './tasks/gulpTsc' import { makePathMap } from './utils/makePathMap' import { makePackage } from './tasks/gulpMakePackage' @@ -19,6 +19,7 @@ import { setGulpGlobal } from './gulpConstants' import { exitAfterAll } from './tasks/gulpRegistry' import { execSync } from 'child_process' import { webpackRunner } from './tasks/gulpWebpack' +import { e2eTestScaffold, e2eTestScaffoldWatch } from './tasks/gulpE2ETestScaffold' /**------------------------------------------------------------------------ * Local Development Workflow @@ -46,6 +47,9 @@ gulp.task( // ... and generate the correct GraphQL types for the frontend graphqlCodegenWatch, + + // ... and generate the patsh for the e2e support file watching + e2eTestScaffoldWatch, ), ) @@ -71,6 +75,7 @@ gulp.task( viteApp, viteLaunchpad, webpackRunner, + e2eTestScaffoldWatch, ), // And we're finally ready for electron, watching for changes in @@ -123,6 +128,7 @@ gulp.task('buildProd', viteBuildApp, viteBuildLaunchpad, ), + symlinkViteProjects, )) gulp.task( @@ -133,6 +139,19 @@ gulp.task( ), ) +gulp.task('watchForE2E', gulp.series( + 'codegen', + gulp.parallel( + gulp.series( + viteBuildAndWatchLaunchpad, + viteBuildAndWatchApp, + ), + webpackRunner, + ), + symlinkViteProjects, + e2eTestScaffold, +)) + /**------------------------------------------------------------------------ * Launchpad Testing * This task builds and hosts the launchpad as if it was a static website. @@ -176,10 +195,12 @@ const cyOpenLaunchpad = gulp.series( // This watches for changes and is not the same things as statically // building the app for production. gulp.parallel( - viteBuildApp, viteBuildAndWatchLaunchpad, + viteBuildApp, ), + symlinkViteProjects, + // 2. Start the REAL (dev) Cypress App, which will launch in open mode. openCypressLaunchpad, ) @@ -196,6 +217,8 @@ const cyOpenApp = gulp.series( webpackRunner, ), + symlinkViteProjects, + // 2. Start the REAL (dev) Cypress App, which will launch in open mode. openCypressApp, ) @@ -242,6 +265,7 @@ gulp.task(makePackage) * here for debugging, e.g. `yarn gulp syncRemoteGraphQL` *------------------------------------------------------------------------**/ +gulp.task(symlinkViteProjects) gulp.task(syncRemoteGraphQL) gulp.task(generateFrontendSchema) gulp.task(makePathMap) @@ -265,6 +289,8 @@ gulp.task('debugCypressLaunchpad', gulp.series( openCypressLaunchpad, )) +gulp.task(e2eTestScaffoldWatch) +gulp.task(e2eTestScaffold) gulp.task(startCypressWatch) gulp.task(openCypressApp) gulp.task(openCypressLaunchpad) diff --git a/scripts/gulp/tasks/gulpE2ETestScaffold.ts b/scripts/gulp/tasks/gulpE2ETestScaffold.ts new file mode 100644 index 0000000000..c07e994469 --- /dev/null +++ b/scripts/gulp/tasks/gulpE2ETestScaffold.ts @@ -0,0 +1,52 @@ +import chokidar from 'chokidar' +import path from 'path' +import fs from 'fs-extra' + +import { monorepoPaths } from '../monorepoPaths' + +const PROJECT_FIXTURE_DIRECTORY = 'test/support/fixtures/projects' + +const DIR_PATH = path.join(monorepoPaths.pkgServer, PROJECT_FIXTURE_DIRECTORY) +const OUTPUT_PATH = path.join(monorepoPaths.pkgFrontendShared, 'cypress/e2e/support/e2eProjectDirs.ts') + +export async function e2eTestScaffold () { + const possibleDirectories = await fs.readdir(DIR_PATH) + const dirs = await Promise.all(possibleDirectories.map(async (dir) => { + const fullPath = path.join(DIR_PATH, dir) + const stat = await fs.stat(fullPath) + + if (stat.isDirectory()) { + return fullPath + } + })) + const allDirs = dirs.filter((dir) => dir) as string[] + + await fs.writeFile( + OUTPUT_PATH, +`/* eslint-disable */ +// Auto-generated by ${path.basename(__filename)} +export const e2eProjectDirs = [ +${allDirs +.map((dir) => ` '${path.basename(dir)}'`).join(',\n')} +] as const +`, + ) + + return allDirs +} + +export async function e2eTestScaffoldWatch () { + const fixtureWatcher = chokidar.watch(PROJECT_FIXTURE_DIRECTORY, { + cwd: monorepoPaths.pkgServer, + // ignoreInitial: true, + depth: 0, + }) + + fixtureWatcher.on('unlinkDir', () => { + e2eTestScaffold() + }) + + fixtureWatcher.on('addDir', () => { + e2eTestScaffold() + }) +} diff --git a/scripts/gulp/tasks/gulpVite.ts b/scripts/gulp/tasks/gulpVite.ts index 766486698d..6e5b311403 100644 --- a/scripts/gulp/tasks/gulpVite.ts +++ b/scripts/gulp/tasks/gulpVite.ts @@ -72,6 +72,27 @@ function spawnViteDevServer ( * * viteBuildLaunchpad *------------------------------------------------------------------------**/ +export async function symlinkViteProjects () { + await Promise.all([ + spawned('cmd-symlink', 'ln -s ../app/dist dist-app', { + cwd: monorepoPaths.pkgLaunchpad, + waitForExit: true, + }).catch((e) => {}), + spawned('cmd-symlink', 'ln -s dist dist-app', { + cwd: monorepoPaths.pkgApp, + waitForExit: true, + }).catch((e) => {}), + spawned('cmd-symlink', 'ln -s dist dist-launchpad', { + cwd: monorepoPaths.pkgLaunchpad, + waitForExit: true, + }).catch((e) => {}), + spawned('cmd-symlink', 'ln -s ../launchpad/dist dist-launchpad', { + cwd: monorepoPaths.pkgApp, + waitForExit: true, + }).catch((e) => {}), + ]) +} + export function viteBuildApp () { return spawned('vite:build-app', `yarn vite build`, { cwd: monorepoPaths.pkgApp, diff --git a/scripts/gulp/utils/childProcessUtils.ts b/scripts/gulp/utils/childProcessUtils.ts index 3d3cd9f2e4..abf03c602c 100644 --- a/scripts/gulp/utils/childProcessUtils.ts +++ b/scripts/gulp/utils/childProcessUtils.ts @@ -7,6 +7,7 @@ import { prefixLog, prefixStream } from './prefixStream' import { addChildProcess } from '../tasks/gulpRegistry' export type AllSpawnableApps = + | `cmd-${string}` | `vite-${string}` | `vite:build-${string}` | `serve:${string}` diff --git a/yarn.lock b/yarn.lock index 4477c10d16..8eafbfe696 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9045,6 +9045,14 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== +"@types/rimraf@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-3.0.2.tgz#a63d175b331748e5220ad48c901d7bbf1f44eef8" + integrity sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ== + dependencies: + "@types/glob" "*" + "@types/node" "*" + "@types/semver@7.3.4": version "7.3.4" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.4.tgz#43d7168fec6fa0988bb1a513a697b29296721afb"