diff --git a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts index d49d3bf8aaba..5e553e0e9ddd 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts @@ -395,4 +395,37 @@ describe('Cypress in Cypress', { viewportWidth: 1500, defaultCommandTimeout: 100 expect(ctx.actions.project.initializeActiveProject).to.be.called }) }) + + describe('runSpec mutation', () => { + it('should trigger expected spec from POST', () => { + startAtSpecsPage('e2e') + + cy.contains('E2E specs').should('be.visible') + + cy.withCtx(async (ctx) => { + const url = `http://127.0.0.1:${ctx.gqlServerPort}/__launchpad/graphql?` + const payload = `{"query":"mutation{\\nrunSpec(specPath:\\"cypress/e2e/dom-content.spec.js\\"){\\n__typename\\n... on RunSpecResponse{\\ntestingType\\nbrowser{\\nid\\nname\\n}\\nspec{\\nid\\nname\\n}\\n}\\n}\\n}","variables":null}` + + ctx.coreData.app.browserStatus = 'open' + + /* + Note: If this test starts failing, this fetch is the likely culprit. + Validate the GQL payload above is still valid by logging the fetch response JSON + */ + + await ctx.util.fetch( + url, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: payload, + }, + ) + }) + + cy.contains('Dom Content').should('be.visible') + }) + }) }) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index ab929b03b5d3..e8cb2180f81b 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -127,10 +127,6 @@ export class EventManager { runnerUiStore.setAutomationStatus(connected) }) - this.ws.on('change:to:url', (url) => { - window.location.href = url - }) - this.ws.on('update:telemetry:context', (contextString) => { const context = JSON.parse(contextString) @@ -815,7 +811,12 @@ export class EventManager { stop () { this.localBus.removeAllListeners() + + // Grab existing listeners for url change event, we want to preserve them + const urlChangeListeners = this.ws.listeners('change:to:url') + this.ws.off() + urlChangeListeners.forEach((listener) => this.ws.on('change:to:url', listener)) } async teardown (state: MobxRunnerStore, isRerun = false) { diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index 4e1dfe0053d7..69dd26557db8 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -37,6 +37,10 @@ export function createWebsocket (config: Cypress.Config) { ws.emit('runner:connected') }) + ws.on('change:to:url', (url) => { + window.location.href = url + }) + return ws } diff --git a/packages/config/src/browser.ts b/packages/config/src/browser.ts index 509886eb5dee..94d21f2bf7e6 100644 --- a/packages/config/src/browser.ts +++ b/packages/config/src/browser.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import Debug from 'debug' import { defaultSpecPattern, + defaultExcludeSpecPattern, options, breakingOptions, breakingRootOptions, @@ -17,6 +18,7 @@ import * as validation from './validation' export { defaultSpecPattern, + defaultExcludeSpecPattern, options, breakingOptions, BreakingOption, diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 2a48153160ba..d32010375a55 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -120,6 +120,11 @@ export const defaultSpecPattern = { component: '**/*.cy.{js,jsx,ts,tsx}', } +export const defaultExcludeSpecPattern = { + e2e: '*.hot-update.js', + component: ['**/__snapshots__/*', '**/__image_snapshots__/*'], +} + // NOTE: // If you add/remove/change a config value, make sure to update the following // - cli/types/index.d.ts (including allowed config options on TestOptions) @@ -271,7 +276,7 @@ const driverConfigOptions: Array = [ requireRestartOnChange: 'server', }, { name: 'excludeSpecPattern', - defaultValue: (options: Record = {}) => options.testingType === 'component' ? ['**/__snapshots__/*', '**/__image_snapshots__/*'] : '*.hot-update.js', + defaultValue: (options: Record = {}) => options.testingType === 'component' ? defaultExcludeSpecPattern.component : defaultExcludeSpecPattern.e2e, validation: validate.isStringOrArrayOfStrings, overrideLevel: 'any', }, { diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index e0f52325c0b1..d5d28934b2e9 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -12,6 +12,14 @@ import templates from '../codegen/templates' import { insertValuesInConfigFile } from '../util' import { getError } from '@packages/errors' import { resetIssuedWarnings } from '@packages/config' +import type { RunSpecErrorCode } from '@packages/graphql/src/schemaTypes' +import debugLib from 'debug' + +export class RunSpecError extends Error { + constructor (public code: typeof RunSpecErrorCode[number], msg: string) { + super(msg) + } +} export interface ProjectApiShape { /** @@ -46,6 +54,7 @@ export interface ProjectApiShape { isListening: (url: string) => Promise resetBrowserTabsForNextTest(shouldKeepTabOpen: boolean): Promise resetServer(): void + runSpec(spec: Cypress.Spec): Promise } export interface FindSpecs { @@ -76,6 +85,8 @@ type SetForceReconfigureProjectByTestingType = { testingType?: TestingType } +const debug = debugLib('cypress:data-context:ProjectActions') + export class ProjectActions { constructor (private ctx: DataContext) {} @@ -462,4 +473,165 @@ export class ProjectActions { await this.ctx.actions.wizard.scaffoldTestingType() } } + + async runSpec ({ specPath }: { specPath: string}) { + const waitForBrowserToOpen = async () => { + const browserStatusSubscription = this.ctx.emitter.subscribeTo('browserStatusChange', { sendInitial: false }) + + // Wait for browser to finish launching. Browser is either launched from scratch + // or relaunched when switching testing types - we need to wait in either case + // We wait a maximum of 3 seconds so we don't block indefinitely in case something + // goes sideways with the browser launch process. This is broken up into three + // separate 'waits' in case we have to watch a browser relaunch (close > opening > open) + debug('Waiting for browser to report `open`') + let maxIterations = 3 + + while (this.ctx.coreData.app.browserStatus !== 'open') { + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, 1000)), + browserStatusSubscription.next(), + ]) + + if (--maxIterations === 0) { + break + } + } + + await browserStatusSubscription.return(undefined as any) + } + + try { + if (!this.ctx.currentProject) { + throw new RunSpecError('NO_PROJECT', 'A project must be open prior to attempting to run a spec') + } + + if (!specPath) { + throw new RunSpecError('NO_SPEC_PATH', '`specPath` must be a non-empty string') + } + + let targetTestingType: TestingType + + // Check to see whether input specPath matches the specPattern for one or the other testing type + // If it matches neither then we can't run the spec and we should error + if (await this.ctx.project.matchesSpecPattern(specPath, 'e2e')) { + targetTestingType = 'e2e' + } else if (await this.ctx.project.matchesSpecPattern(specPath, 'component')) { + targetTestingType = 'component' + } else { + throw new RunSpecError('NO_SPEC_PATTERN_MATCH', 'Unable to determine testing type, spec does not match any configured specPattern') + } + + debug(`Spec %s matches '${targetTestingType}' pattern`, specPath) + + const absoluteSpecPath = this.ctx.path.resolve(this.ctx.currentProject, specPath) + + debug('Attempting to launch spec %s', absoluteSpecPath) + + // Look to see if there's actually a file at the target location + // This helps us avoid switching testingType *then* finding out the spec doesn't exist + if (!this.ctx.fs.existsSync(absoluteSpecPath)) { + throw new RunSpecError('SPEC_NOT_FOUND', `No file exists at path ${absoluteSpecPath}`) + } + + // We now know what testingType we need to be in - if we're already there, great + // If not, verify that type is configured then switch (or throw an error if not configured) + if (this.ctx.coreData.currentTestingType !== targetTestingType) { + if (!this.ctx.lifecycleManager.isTestingTypeConfigured(targetTestingType)) { + throw new RunSpecError('TESTING_TYPE_NOT_CONFIGURED', `Input path matched specPattern for '${targetTestingType}' testing type, but it is not configured.`) + } + + debug('Setting testing type to %s', targetTestingType) + + const specChangeSubscription = this.ctx.emitter.subscribeTo('specsChange', { sendInitial: false }) + + const originalTestingType = this.ctx.coreData.currentTestingType + + // Temporarily toggle testing type so the `activeBrowser` can be initialized + // for the targeted testing type. Browser has to be initialized prior to our "relaunch" + // call below - this can be an issue when Cypress is still on the launchpad and no + // browser has been launched yet + this.ctx.lifecycleManager.setCurrentTestingType(targetTestingType) + await this.ctx.lifecycleManager.setInitialActiveBrowser() + this.ctx.lifecycleManager.setCurrentTestingType(originalTestingType) + + // This is the magic sauce - we now have a browser selected, so this will toggle + // the testing type, trigger specs to update, and launch the browser + await this.switchTestingTypesAndRelaunch(targetTestingType) + + await waitForBrowserToOpen() + + // When testing type changes we need to wait for the specWatcher to trigger and load new specs + // otherwise our call to `getCurrentSpecByAbsolute` below will fail + // Wait a maximum of 2 seconds just in case something breaks with the event subscription + // so we don't block indefinitely + debug('Waiting for specs to finish loading') + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, 2000)), + specChangeSubscription.next(), + ]) + + // Close out subscription + await specChangeSubscription.return(undefined) + } else { + debug('Already in %s testing mode', targetTestingType) + } + + // This accounts for an edge case where a testing type has been previously opened, but + // the user then backs out to the testing type selector in launchpad. In that scenario, + // the testingType switch logic above does not trigger the browser to open, so we do it + // manually here + if (this.ctx.coreData.app.browserStatus === 'closed') { + debug('No browser instance, launching...') + await this.ctx.lifecycleManager.setInitialActiveBrowser() + + await this.api.launchProject( + this.ctx.coreData.activeBrowser!, + { + name: '', + absolute: '', + relative: '', + specType: targetTestingType === 'e2e' ? 'integration' : 'component', + }, + ) + + debug('Browser launched') + } else { + debug(`Browser already running, status ${this.ctx.coreData.app.browserStatus}`) + + if (this.ctx.coreData.app.browserStatus !== 'open') { + await waitForBrowserToOpen() + } + } + + // Now that we're in the correct testingType, verify the requested spec actually exists + // We don't have specs available until a testingType is loaded, so even through we validated + // a matching file exists above it may not end up loading as a valid spec so we validate that here + const spec = this.ctx.project.getCurrentSpecByAbsolute(absoluteSpecPath) + + if (!spec) { + throw new RunSpecError('SPEC_NOT_FOUND', `Unable to find matching spec with path ${absoluteSpecPath}`) + } + + const browser = this.ctx.coreData.activeBrowser! + + // Hooray, everything looks good and we're all set up + // Try to launch the requested spec by navigating to it in the browser + await this.api.runSpec(spec) + + return { + testingType: targetTestingType, + browser, + spec, + } + } catch (err) { + if (!(err instanceof RunSpecError)) { + debug('Unexpected error during `runSpec` %o', err) + } + + return { + code: err instanceof RunSpecError ? err.code : 'GENERAL_ERROR', + detailMessage: err.message, + } + } + } } diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index 2c1c4a6a9ad7..afac43214656 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -584,7 +584,7 @@ export class ProjectLifecycleManager { * this sources the config from the various config sources */ async getFullInitialConfig (options: Partial = this.ctx.modeOptions, withBrowsers = true): Promise { - assert(this._configManager, 'Cannot get full config a config manager') + assert(this._configManager, 'Cannot get full config without a config manager') return this._configManager.getFullInitialConfig(options, withBrowsers) } diff --git a/packages/data-context/src/sources/ProjectDataSource.ts b/packages/data-context/src/sources/ProjectDataSource.ts index b8cea8e9fa63..95284e536cb8 100644 --- a/packages/data-context/src/sources/ProjectDataSource.ts +++ b/packages/data-context/src/sources/ProjectDataSource.ts @@ -7,7 +7,7 @@ import path from 'path' import Debug from 'debug' import commonPathPrefix from 'common-path-prefix' import type { FSWatcher } from 'chokidar' -import { defaultSpecPattern } from '@packages/config' +import { defaultSpecPattern, defaultExcludeSpecPattern } from '@packages/config' import parseGlob from 'parse-glob' import micromatch from 'micromatch' import RandExp from 'randexp' @@ -23,12 +23,20 @@ import type { ProjectShape } from '../data' import type { FindSpecs } from '../actions' import { FileExtension, getDefaultSpecFileName } from './migration/utils' +type SpecPatterns = { + specPattern?: string[] + excludeSpecPattern?: string[] +} + interface MatchedSpecs { projectRoot: string testingType: Cypress.TestingType specAbsolutePaths: string[] specPattern: string | string[] } + +const toArray = (val?: string | string[]) => val ? typeof val === 'string' ? [val] : val : undefined + export function matchedSpecs ({ projectRoot, testingType, @@ -258,12 +266,10 @@ export class ProjectDataSource { this.ctx.coreData.app.relaunchBrowser = relaunchBrowser } - async specPatterns (): Promise<{ - specPattern?: string[] - excludeSpecPattern?: string[] - }> { - const toArray = (val?: string | string[]) => val ? typeof val === 'string' ? [val] : val : undefined - + /** + * Retrieve the applicable spec patterns for the current testing type + */ + async specPatterns (): Promise { const config = await this.getConfig() return { @@ -272,6 +278,26 @@ export class ProjectDataSource { } } + /** + * Retrieve the applicable spec patterns for a given testing type. Can be used to check whether + * a spec satisfies the pattern when outside a given testing type. + */ + async specPatternsByTestingType (testingType: TestingType): Promise { + const configFile = await this.ctx.lifecycleManager.getConfigFileContents() + + if (testingType === 'e2e') { + return { + specPattern: toArray(configFile.e2e?.specPattern ?? defaultSpecPattern.e2e), + excludeSpecPattern: toArray(configFile.e2e?.excludeSpecPattern ?? defaultExcludeSpecPattern.e2e), + } + } + + return { + specPattern: toArray(configFile.component?.specPattern ?? defaultSpecPattern.component), + excludeSpecPattern: toArray(configFile.component?.excludeSpecPattern ?? defaultExcludeSpecPattern.component), + } + } + async findSpecs ({ projectRoot, testingType, @@ -460,14 +486,21 @@ export class ProjectDataSource { }) } - async matchesSpecPattern (specFile: string): Promise { - if (!this.ctx.currentProject || !this.ctx.coreData.currentTestingType) { + /** + * Determines whether a given spec file satisfies the spec pattern *and* does not satisfy any + * exclusionary pattern. By default it will check the spec pattern for the currently-active + * testing type, but a target testing type can be supplied via optional parameter. + */ + async matchesSpecPattern (specFile: string, testingType?: TestingType): Promise { + const targetTestingType = testingType || this.ctx.coreData.currentTestingType + + if (!this.ctx.currentProject || !targetTestingType) { return false } const MINIMATCH_OPTIONS = { dot: true, matchBase: true } - const { specPattern = [], excludeSpecPattern = [] } = await this.ctx.project.specPatterns() + const { specPattern = [], excludeSpecPattern = [] } = await this.ctx.project.specPatternsByTestingType(targetTestingType) for (const pattern of excludeSpecPattern) { if (minimatch(specFile, pattern, MINIMATCH_OPTIONS)) { diff --git a/packages/data-context/test/unit/actions/ProjectActions.spec.ts b/packages/data-context/test/unit/actions/ProjectActions.spec.ts index dc95bf2175a1..b814efe85adc 100644 --- a/packages/data-context/test/unit/actions/ProjectActions.spec.ts +++ b/packages/data-context/test/unit/actions/ProjectActions.spec.ts @@ -103,4 +103,165 @@ describe('ProjectActions', () => { }) }) }) + + describe('runSpec', () => { + context('no project', () => { + it('should fail with `NO_PROJECT`', async () => { + const result = await ctx.actions.project.runSpec({ specPath: 'e2e/abc.cy.ts' }) + + sinon.assert.match(result, { + code: 'NO_PROJECT', + detailMessage: sinon.match.string, + }) + }) + }) + + context('empty specPath', () => { + beforeEach(() => { + ctx.coreData.currentProject = '/cy-project' + }) + + it('should fail with `NO_SPEC_PATH`', async () => { + const result = await ctx.actions.project.runSpec({ specPath: '' }) + + sinon.assert.match(result, { + code: 'NO_SPEC_PATH', + detailMessage: sinon.match.string, + }) + }) + }) + + context('no specPattern match', () => { + beforeEach(() => { + ctx.coreData.currentProject = '/cy-project' + sinon.stub(ctx.project, 'matchesSpecPattern').resolves(false) + }) + + it('should fail with `NO_SPEC_PATTERN_MATCH`', async () => { + const result = await ctx.actions.project.runSpec({ specPath: 'e2e/abc.cy.ts' }) + + sinon.assert.match(result, { + code: 'NO_SPEC_PATTERN_MATCH', + detailMessage: sinon.match.string, + }) + }) + }) + + context('spec file not found', () => { + beforeEach(() => { + ctx.coreData.currentProject = '/cy-project' + sinon.stub(ctx.project, 'matchesSpecPattern').withArgs(sinon.match.string, 'e2e').resolves(true) + sinon.stub(ctx.fs, 'existsSync').returns(false) + }) + + it('should fail with `SPEC_NOT_FOUND`', async () => { + const result = await ctx.actions.project.runSpec({ specPath: 'e2e/abc.cy.ts' }) + + sinon.assert.match(result, { + code: 'SPEC_NOT_FOUND', + detailMessage: sinon.match.string, + }) + }) + }) + + context('matched testing type not configured', () => { + beforeEach(() => { + ctx.coreData.currentTestingType = null + ctx.coreData.currentProject = '/cy-project' + sinon.stub(ctx.project, 'matchesSpecPattern').withArgs(sinon.match.string, 'e2e').resolves(true) + sinon.stub(ctx.fs, 'existsSync').returns(true) + sinon.stub(ctx.lifecycleManager, 'isTestingTypeConfigured').withArgs('e2e').returns(false) + }) + + it('should fail with `TESTING_TYPE_NOT_CONFIGURED`', async () => { + const result = await ctx.actions.project.runSpec({ specPath: 'e2e/abc.cy.ts' }) + + sinon.assert.match(result, { + code: 'TESTING_TYPE_NOT_CONFIGURED', + detailMessage: sinon.match.string, + }) + }) + }) + + context('spec can be executed', () => { + beforeEach(() => { + ctx.coreData.currentProject = '/cy-project' + sinon.stub(ctx.project, 'matchesSpecPattern').withArgs(sinon.match.string, 'e2e').resolves(true) + sinon.stub(ctx.fs, 'existsSync').returns(true) + sinon.stub(ctx.project, 'getCurrentSpecByAbsolute').returns({ id: 'xyz' } as any) + sinon.stub(ctx.lifecycleManager, 'setInitialActiveBrowser') + ctx.coreData.activeBrowser = { id: 'abc' } as any + sinon.stub(ctx.lifecycleManager, 'setCurrentTestingType') + sinon.stub(ctx.actions.project, 'switchTestingTypesAndRelaunch') + ctx.coreData.app.browserStatus = 'open' + sinon.stub(ctx.emitter, 'subscribeTo').returns({ + next: () => {}, + return: () => {}, + } as any) + }) + + context('no current testing type', () => { + beforeEach(() => { + ctx.coreData.currentTestingType = null + sinon.stub(ctx.lifecycleManager, 'isTestingTypeConfigured').withArgs('e2e').returns(true) + }) + + it('should succeed', async () => { + const result = await ctx.actions.project.runSpec({ specPath: 'e2e/abc.cy.ts' }) + + sinon.assert.match(result, { + testingType: 'e2e', + browser: sinon.match.object, + spec: sinon.match.object, + }) + + expect(ctx.lifecycleManager.setCurrentTestingType).to.have.been.calledWith('e2e') + expect(ctx.actions.project.switchTestingTypesAndRelaunch).to.have.been.calledWith('e2e') + expect(ctx._apis.projectApi.runSpec).to.have.been.called + }) + }) + + context('testing type needs to change', () => { + beforeEach(() => { + ctx.coreData.currentTestingType = 'component' + sinon.stub(ctx.lifecycleManager, 'isTestingTypeConfigured').withArgs('e2e').returns(true) + }) + + it('should succeed', async () => { + const result = await ctx.actions.project.runSpec({ specPath: 'e2e/abc.cy.ts' }) + + sinon.assert.match(result, { + testingType: 'e2e', + browser: sinon.match.object, + spec: sinon.match.object, + }) + + expect(ctx.lifecycleManager.setCurrentTestingType).to.have.been.calledWith('e2e') + expect(ctx.actions.project.switchTestingTypesAndRelaunch).to.have.been.calledWith('e2e') + expect(ctx._apis.projectApi.runSpec).to.have.been.called + }) + }) + + context('testing type does not need to change', () => { + beforeEach(() => { + ctx.coreData.currentTestingType = 'e2e' + }) + + it('should succeed', async () => { + const result = await ctx.actions.project.runSpec({ specPath: 'e2e/abc.cy.ts' }) + + sinon.assert.match(result, { + testingType: 'e2e', + browser: sinon.match.object, + spec: sinon.match.object, + }) + + expect(ctx.lifecycleManager.setCurrentTestingType).not.to.have.been.called + expect(ctx.actions.project.switchTestingTypesAndRelaunch).not.to.have.been.called + + expect(ctx._apis.projectApi.runSpec).to.have.been.called + }) + }) + }) + }) }) diff --git a/packages/data-context/test/unit/helper.ts b/packages/data-context/test/unit/helper.ts index 417d74167e06..bc7bf3288afb 100644 --- a/packages/data-context/test/unit/helper.ts +++ b/packages/data-context/test/unit/helper.ts @@ -56,6 +56,7 @@ export function createTestDataContext (mode: DataContextConfig['mode'] = 'run', closeActiveProject: sinon.stub(), insertProjectToCache: sinon.stub().resolves(), getProjectRootsFromCache: sinon.stub().resolves([]), + runSpec: sinon.stub(), } as unknown as ProjectApiShape, electronApi: { isMainWindowFocused: sinon.stub().returns(false), diff --git a/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts b/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts index d29e5ffe690e..d50d56c4af9d 100644 --- a/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts @@ -12,7 +12,7 @@ import { FoundSpec } from '@packages/types' import { DataContext } from '../../../src' import type { FindSpecs } from '../../../src/actions' import { createTestDataContext } from '../helper' -import { defaultSpecPattern } from '@packages/config' +import { defaultExcludeSpecPattern, defaultSpecPattern } from '@packages/config' import FixturesHelper from '@tooling/system-tests' chai.use(sinonChai) @@ -827,6 +827,7 @@ describe('ProjectDataSource', () => { it('yields correct jsx extension if there are jsx files and specPattern allows', async () => { sinon.stub(ctx.project, 'specPatterns').resolves({ specPattern: [defaultSpecPattern.component] }) + sinon.stub(ctx.project, 'specPatternsByTestingType').resolves({ specPattern: [defaultSpecPattern.component] }) const defaultSpecFileName = await ctx.project.defaultSpecFileName() @@ -835,6 +836,7 @@ describe('ProjectDataSource', () => { it('yields non-jsx extension if there are jsx files but specPattern disallows', async () => { sinon.stub(ctx.project, 'specPatterns').resolves({ specPattern: ['cypress/component/*.cy.js'] }) + sinon.stub(ctx.project, 'specPatternsByTestingType').resolves({ specPattern: ['cypress/component/*.cy.js'] }) const defaultSpecFileName = await ctx.project.defaultSpecFileName() @@ -843,4 +845,59 @@ describe('ProjectDataSource', () => { }) }) }) + + describe('specPatternsByTestingType', () => { + context('when custom patterns configured', () => { + beforeEach(() => { + sinon.stub(ctx.lifecycleManager, 'getConfigFileContents').resolves({ + e2e: { + specPattern: 'abc', + excludeSpecPattern: 'def', + }, + component: { + specPattern: 'uvw', + excludeSpecPattern: 'xyz', + } as any, + }) + }) + + it('should return custom e2e patterns', async () => { + expect(await ctx.project.specPatternsByTestingType('e2e')).to.eql({ + specPattern: ['abc'], + excludeSpecPattern: ['def'], + }) + }) + + it('should return custom component patterns', async () => { + expect(await ctx.project.specPatternsByTestingType('component')).to.eql({ + specPattern: ['uvw'], + excludeSpecPattern: ['xyz'], + }) + }) + }) + + context('when no custom patterns configured', () => { + const wrapInArray = (value: string | string[]): string[] => { + return Array.isArray(value) ? value : [value] + } + + beforeEach(() => { + sinon.stub(ctx.lifecycleManager, 'getConfigFileContents').resolves({}) + }) + + it('should return default e2e patterns', async () => { + expect(await ctx.project.specPatternsByTestingType('e2e')).to.eql({ + specPattern: wrapInArray(defaultSpecPattern.e2e), + excludeSpecPattern: wrapInArray(defaultExcludeSpecPattern.e2e), + }) + }) + + it('should return default component patterns', async () => { + expect(await ctx.project.specPatternsByTestingType('component')).to.eql({ + specPattern: wrapInArray(defaultSpecPattern.component), + excludeSpecPattern: wrapInArray(defaultExcludeSpecPattern.component), + }) + }) + }) + }) }) diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index 1fbab1c5811c..572d6ada5b25 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -1748,6 +1748,16 @@ type Mutation { """Reset the Wizard to the starting position""" resetWizard: Boolean! + + """ + Run a single spec file using a supplied path. This initiates but does not wait for completion of the requested spec run. + """ + runSpec( + """ + Relative path of spec to run from Cypress project root - must match e2e or component specPattern + """ + specPath: String! + ): RunSpecResult scaffoldTestingType: Query setAndLoadCurrentTestingType(testingType: TestingTypeEnum!): Query @@ -2178,6 +2188,39 @@ enum RunInstanceStatusEnum { UNCLAIMED } +"""Error encountered during a runSpec mutation""" +type RunSpecError { + code: RunSpecErrorCode! + + """ + Contextual information for the error (typically an expanded error message) + """ + detailMessage: String +} + +enum RunSpecErrorCode { + GENERAL_ERROR + NO_PROJECT + NO_SPEC_PATH + NO_SPEC_PATTERN_MATCH + SPEC_NOT_FOUND + TESTING_TYPE_NOT_CONFIGURED +} + +"""Result of a runSpec mutation""" +type RunSpecResponse { + """Browser test was launched in""" + browser: Browser! + + """Matched spec that was launched""" + spec: Spec! + + """Testing Type that spec was launched in""" + testingType: String! +} + +union RunSpecResult = RunSpecError | RunSpecResponse + """A file that we just added to the filesystem during project setup""" type ScaffoldedFile { """Info about the file we just scaffolded""" diff --git a/packages/graphql/src/schemaTypes/enumTypes/gql-RunSpecErrorCodeEnum.ts b/packages/graphql/src/schemaTypes/enumTypes/gql-RunSpecErrorCodeEnum.ts new file mode 100644 index 000000000000..244d9ad4f0e1 --- /dev/null +++ b/packages/graphql/src/schemaTypes/enumTypes/gql-RunSpecErrorCodeEnum.ts @@ -0,0 +1,8 @@ +import { enumType } from 'nexus' + +export const RunSpecErrorCode = ['NO_PROJECT', 'NO_SPEC_PATH', 'NO_SPEC_PATTERN_MATCH', 'TESTING_TYPE_NOT_CONFIGURED', 'SPEC_NOT_FOUND', 'GENERAL_ERROR'] as const + +export const RunSpecErrorCodeEnum = enumType({ + name: 'RunSpecErrorCode', + members: RunSpecErrorCode, +}) diff --git a/packages/graphql/src/schemaTypes/enumTypes/index.ts b/packages/graphql/src/schemaTypes/enumTypes/index.ts index f16606e3e88b..c3be7ed3f035 100644 --- a/packages/graphql/src/schemaTypes/enumTypes/index.ts +++ b/packages/graphql/src/schemaTypes/enumTypes/index.ts @@ -8,5 +8,6 @@ export * from './gql-ErrorTypeEnum' export * from './gql-FileExtensionEnum' export * from './gql-PreferencesTypeEnum' export * from './gql-ProjectEnums' +export * from './gql-RunSpecErrorCodeEnum' export * from './gql-SpecEnum' export * from './gql-WizardEnums' diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 92a919b3b381..3e7f04e8121f 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -1,8 +1,6 @@ import { arg, booleanArg, enumType, idArg, mutationType, nonNull, stringArg, list, intArg } from 'nexus' import { Wizard } from './gql-Wizard' -import { CodeGenTypeEnum } from '../enumTypes/gql-CodeGenTypeEnum' -import { TestingTypeEnum } from '../enumTypes/gql-WizardEnums' -import { PreferencesTypeEnum } from '../enumTypes/gql-PreferencesTypeEnum' +import { CodeGenTypeEnum, TestingTypeEnum, PreferencesTypeEnum } from '../enumTypes' import { FileDetailsInput } from '../inputTypes/gql-FileDetailsInput' import { WizardUpdateInput } from '../inputTypes/gql-WizardUpdateInput' import { CurrentProject } from './gql-CurrentProject' @@ -13,6 +11,7 @@ import { ScaffoldedFile } from './gql-ScaffoldedFile' import debugLib from 'debug' import { ReactComponentResponse } from './gql-ReactComponentResponse' import { TestsBySpecInput } from '../inputTypes' +import { RunSpecResult } from '../unions' const debug = debugLib('cypress:graphql:mutation') @@ -634,6 +633,21 @@ export const mutation = mutationType({ }, }) + t.field('runSpec', { + description: 'Run a single spec file using a supplied path. This initiates but does not wait for completion of the requested spec run.', + type: RunSpecResult, + args: { + specPath: nonNull(stringArg({ + description: 'Relative path of spec to run from Cypress project root - must match e2e or component specPattern', + })), + }, + resolve: async (source, args, ctx) => { + return await ctx.actions.project.runSpec({ + specPath: args.specPath, + }) + }, + }) + t.field('dismissWarning', { type: Query, args: { diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-RunSpecError.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-RunSpecError.ts new file mode 100644 index 000000000000..117cb51f7916 --- /dev/null +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-RunSpecError.ts @@ -0,0 +1,16 @@ +import { objectType } from 'nexus' +import { RunSpecErrorCodeEnum } from '../enumTypes' + +export const RunSpecError = objectType({ + name: 'RunSpecError', + description: 'Error encountered during a runSpec mutation', + definition (t) { + t.nonNull.field('code', { + type: RunSpecErrorCodeEnum, + }) + + t.string('detailMessage', { + description: 'Contextual information for the error (typically an expanded error message)', + }) + }, +}) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-RunSpecResponse.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-RunSpecResponse.ts new file mode 100644 index 000000000000..5e2961fbf525 --- /dev/null +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-RunSpecResponse.ts @@ -0,0 +1,21 @@ +import { objectType } from 'nexus' + +export const RunSpecResponse = objectType({ + name: 'RunSpecResponse', + description: 'Result of a runSpec mutation', + definition (t) { + t.nonNull.string('testingType', { + description: 'Testing Type that spec was launched in', + }) + + t.nonNull.field('browser', { + type: 'Browser', + description: 'Browser test was launched in', + }) + + t.nonNull.field('spec', { + type: 'Spec', + description: 'Matched spec that was launched', + }) + }, +}) diff --git a/packages/graphql/src/schemaTypes/objectTypes/index.ts b/packages/graphql/src/schemaTypes/objectTypes/index.ts index 61323f52854c..c91e335b9df0 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/index.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/index.ts @@ -24,6 +24,8 @@ export * from './gql-Query' export * from './gql-ReactComponentDescriptor' export * from './gql-ReactComponentResponse' export * from './gql-RelevantRun' +export * from './gql-RunSpecError' +export * from './gql-RunSpecResponse' export * from './gql-ScaffoldedFile' export * from './gql-Spec' export * from './gql-Subscription' diff --git a/packages/graphql/src/schemaTypes/unions/gql-RunSpecResult.ts b/packages/graphql/src/schemaTypes/unions/gql-RunSpecResult.ts new file mode 100644 index 000000000000..9398e9bcb12b --- /dev/null +++ b/packages/graphql/src/schemaTypes/unions/gql-RunSpecResult.ts @@ -0,0 +1,14 @@ +import { unionType } from 'nexus' + +export const RunSpecResult = unionType({ + name: 'RunSpecResult', + definition (t) { + t.members( + 'RunSpecResponse', + 'RunSpecError', + ) + }, + resolveType: (obj) => { + return 'code' in obj ? 'RunSpecError' : 'RunSpecResponse' + }, +}) diff --git a/packages/graphql/src/schemaTypes/unions/index.ts b/packages/graphql/src/schemaTypes/unions/index.ts index 66f93711cb1b..1cca8e1aba0c 100644 --- a/packages/graphql/src/schemaTypes/unions/index.ts +++ b/packages/graphql/src/schemaTypes/unions/index.ts @@ -2,3 +2,4 @@ // created by autobarrel, do not modify directly export * from './gql-GeneratedSpecResult' +export * from './gql-RunSpecResult' diff --git a/packages/server/lib/makeDataContext.ts b/packages/server/lib/makeDataContext.ts index 82857e8bc6cb..a3cab7acf137 100644 --- a/packages/server/lib/makeDataContext.ts +++ b/packages/server/lib/makeDataContext.ts @@ -161,6 +161,9 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext { resetServer () { return openProject.getProject()?.server.reset() }, + async runSpec (spec: Cypress.Spec): Promise { + openProject.changeUrlToSpec(spec) + }, }, electronApi: { openExternal (url: string) { diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 46e4199fc9fc..7706321fa97a 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -223,6 +223,8 @@ export class OpenProject { changeUrlToSpec (spec: Cypress.Spec) { if (!this.projectBase) { + debug('No projectBase, cannot change url') + return }