diff --git a/README.md b/README.md
index 8df5cd1a3ef7..9867193933f2 100644
--- a/README.md
+++ b/README.md
@@ -28,8 +28,8 @@
-
-
+
+
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/ElectronActions.ts b/packages/data-context/src/actions/ElectronActions.ts
index afce5ab557ad..091e81d54677 100644
--- a/packages/data-context/src/actions/ElectronActions.ts
+++ b/packages/data-context/src/actions/ElectronActions.ts
@@ -1,4 +1,4 @@
-import type { App, BrowserWindow, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
+import type { App, BrowserWindow, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue, Notification } from 'electron'
import os from 'os'
import type { DataContext } from '..'
import _ from 'lodash'
@@ -13,6 +13,7 @@ export interface ElectronApiShape {
copyTextToClipboard(text: string): void
isMainWindowFocused(): boolean
focusMainWindow(): void
+ createNotification(title: string, body: string): Notification
}
export class ElectronActions {
@@ -104,4 +105,18 @@ export class ElectronActions {
return obj.filePath || null
})
}
+
+ showSystemNotification (title: string, body: string, onClick?: () => void) {
+ const notification = this.ctx.electronApi.createNotification(title, body)
+
+ const defaultOnClick = async () => {
+ await this.ctx.actions.browser.focusActiveBrowserWindow()
+ }
+
+ const clickHandler = onClick || defaultOnClick
+
+ notification.on('click', clickHandler)
+
+ notification.show()
+ }
}
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..051f97132614 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
@@ -1776,6 +1786,9 @@ type Mutation {
"""Set failed tests for the current run to be used by the runner"""
setTestsForRun(testsBySpec: [TestsBySpecInput!]!): Boolean
+ """Show system notification via Electron"""
+ showSystemNotification(body: String!, title: String!): Boolean
+
"""Switch Testing type and relaunch browser"""
switchTestingTypeAndRelaunch(testingType: TestingTypeEnum!): Boolean
@@ -2178,6 +2191,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..c29d019bc02e 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: {
@@ -761,6 +775,19 @@ export const mutation = mutationType({
},
})
+ t.boolean('showSystemNotification', {
+ description: 'Show system notification via Electron',
+ args: {
+ title: nonNull(stringArg()),
+ body: nonNull(stringArg()),
+ },
+ resolve: async (source, args, ctx) => {
+ ctx.actions.electron.showSystemNotification(args.title, args.body)
+
+ return true
+ },
+ })
+
t.boolean('moveToRelevantRun', {
description: 'Allow the relevant run for debugging marked as next to be considered the current relevant run',
args: {
@@ -773,7 +800,7 @@ export const mutation = mutationType({
},
})
- //Using a mutation to just return data in order to be able to await the results in the component
+ // Using a mutation to just return data in order to be able to await the results in the component
t.list.nonNull.string('testsForRun', {
description: 'Return the set of test titles for the given spec path',
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..74b7349df09c 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) {
@@ -186,6 +189,9 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
focusMainWindow () {
return focusMainWindow()
},
+ createNotification (title, body) {
+ return new electron.Notification({ title, body })
+ },
},
localSettingsApi: {
async setPreferences (object: AllowedState) {
diff --git a/packages/server/lib/modes/interactive.ts b/packages/server/lib/modes/interactive.ts
index fe7227cf6369..b76912ca5dd3 100644
--- a/packages/server/lib/modes/interactive.ts
+++ b/packages/server/lib/modes/interactive.ts
@@ -157,6 +157,11 @@ export = {
},
async run (options: LaunchArgs, _loading: Promise) {
+ // Need to set this for system notifications to appear as "Cypress" on Windows
+ if (app.setAppUserModelId) {
+ app.setAppUserModelId('Cypress')
+ }
+
// Note: We do not await the `_loading` promise here since initializing
// the data context can significantly delay initial render of the UI
// https://github.com/cypress-io/cypress/issues/26388#issuecomment-1492616609
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
}
diff --git a/system-tests/projects/config-cjs-and-esm/config-with-ts-module/package.json b/system-tests/projects/config-cjs-and-esm/config-with-ts-module/package.json
index 6019ddbb6d0a..03af11608963 100644
--- a/system-tests/projects/config-cjs-and-esm/config-with-ts-module/package.json
+++ b/system-tests/projects/config-cjs-and-esm/config-with-ts-module/package.json
@@ -1,7 +1,7 @@
{
"dependencies": {
"find-up": "6.3.0",
- "typescript": "4.7.4"
+ "typescript": "4.9.5"
},
"type": "module",
"projectFixtureDirectory": "simple_passing"
diff --git a/system-tests/projects/config-cjs-and-esm/config-with-ts-module/yarn.lock b/system-tests/projects/config-cjs-and-esm/config-with-ts-module/yarn.lock
index 6b28faefe620..06992131d2a9 100644
--- a/system-tests/projects/config-cjs-and-esm/config-with-ts-module/yarn.lock
+++ b/system-tests/projects/config-cjs-and-esm/config-with-ts-module/yarn.lock
@@ -36,10 +36,10 @@ path-exists@^5.0.0:
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7"
integrity "sha1-pqrZSJIAsh+rMeSc8JJ35RFvuec= sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="
-typescript@4.7.4:
- version "4.7.4"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
- integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
+typescript@4.9.5:
+ version "4.9.5"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
+ integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
yocto-queue@^1.0.0:
version "1.0.0"