@revoloo/cypress6
Version:
Cypress.io end to end testing tool
890 lines (716 loc) • 24.1 kB
text/typescript
// @ts-nocheck
import Bluebird from 'bluebird'
import check from 'check-more-types'
import Debug from 'debug'
import EE from 'events'
import la from 'lazy-ass'
import _ from 'lodash'
import path from 'path'
import R from 'ramda'
import commitInfo from '@cypress/commit-info'
import { RunnablesStore } from '@packages/reporter'
import { ServerCt } from '@packages/server-ct'
import api from './api'
import { Automation } from './automation'
import cache from './cache'
import config from './config'
import cwd from './cwd'
import errors from './errors'
import logger from './logger'
import Reporter from './reporter'
import savedState from './saved_state'
import scaffold from './scaffold'
import { ServerE2E } from './server-e2e'
import user from './user'
import { ensureProp } from './util/class-helpers'
import { escapeFilenameInUrl } from './util/escape_filename'
import { fs } from './util/fs'
import keys from './util/keys'
import settings from './util/settings'
import specsUtil from './util/specs'
import Watchers from './watchers'
interface CloseOptions {
onClose: () => any
}
interface OpenOptions {
onOpen: (cfg: any) => Bluebird<any>
onAfterOpen: (cfg: any) => Bluebird<any>
}
// type ProjectOptions = Record<string, any>
export type Cfg = Record<string, any>
const localCwd = cwd()
const multipleForwardSlashesRe = /[^:\/\/](\/{2,})/g
const debug = Debug('cypress:server:project')
const debugScaffold = Debug('cypress:server:scaffold')
export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
protected projectRoot: string
protected watchers: Watchers
protected options?: Record<string, any>
protected spec: Cypress.Cypress['spec'] | null
protected _cfg?: Cfg
protected _server?: TServer
protected _automation?: Automation
private _recordTests = null
public browser: any
constructor (projectRoot: string) {
super()
if (!projectRoot) {
throw new Error('Instantiating lib/project requires a projectRoot!')
}
if (!check.unemptyString(projectRoot)) {
throw new Error(`Expected project root path, not ${projectRoot}`)
}
this.projectRoot = path.resolve(projectRoot)
this.watchers = new Watchers()
this.spec = null
this.browser = null
debug('Project created %o', {
projectType: this.projectType,
projectRoot: this.projectRoot,
})
}
protected ensureProp = ensureProp
get projectType () {
if (this.constructor === ProjectBase) {
return 'base'
}
throw new Error('Project#projectType must be defined')
}
setOnTestsReceived (fn) {
this._recordTests = fn
}
get server () {
return this.ensureProp(this._server, 'open')
}
get automation () {
return this.ensureProp(this._automation, 'open')
}
get cfg () {
return this.ensureProp(this._cfg, 'open')
}
open (options = {}, callbacks: OpenOptions) {
debug('opening project instance %s', this.projectRoot)
debug('project open options %o', options)
_.defaults(options, {
report: false,
onFocusTests () {},
onError () {},
onWarning () {},
onSettingsChanged: false,
})
debug('project options %o', options)
this.options = options
return this.getConfig(options)
.tap((cfg) => {
process.chdir(this.projectRoot)
// attach warning message if user has "chromeWebSecurity: false" for unsupported browser
if (cfg.chromeWebSecurity === false) {
_.chain(cfg.browsers)
.filter((browser) => browser.family !== 'chromium')
.each((browser) => browser.warning = errors.getMsgByType('CHROME_WEB_SECURITY_NOT_SUPPORTED', browser.name))
.value()
}
// TODO: we currently always scaffold the plugins file
// even when headlessly or else it will cause an error when
// we try to load it and it's not there. We must do this here
// else initialing the plugins will instantly fail.
if (cfg.pluginsFile) {
debug('scaffolding with plugins file %s', cfg.pluginsFile)
return scaffold.plugins(path.dirname(cfg.pluginsFile), cfg)
}
})
.then(callbacks.onOpen)
.tap(({ cfg, port, warning }) => {
// if we didnt have a cfg.port
// then get the port once we
// open the server
if (!cfg.port) {
cfg.port = port
// and set all the urls again
_.extend(cfg, config.setUrls(cfg))
}
})
.tap(callbacks.onAfterOpen)
.then(({ cfg, port, warning }) => {
// store the cfg from
// opening the server
this._cfg = cfg
debug('project config: %o', _.omit(cfg, 'resolved'))
if (warning) {
options.onWarning(warning)
}
options.onSavedStateChanged = (state) => this.saveState(state)
return Bluebird.join(
this.watchSettingsAndStartWebsockets(options, cfg),
this.scaffold(cfg),
)
.then(() => {
return Bluebird.join(
this.checkSupportFile(cfg),
this.watchPluginsFile(cfg, options),
)
})
})
.return(this)
}
getRuns () {
return Bluebird.all([
this.getProjectId(),
user.ensureAuthToken(),
])
.spread((projectId, authToken) => {
return api.getProjectRuns(projectId, authToken)
})
}
reset () {
debug('resetting project instance %s', this.projectRoot)
this.spec = null
this.browser = null
return Bluebird.try(() => {
if (this._automation) {
this._automation.reset()
}
if (this._server) {
return this._server.reset()
}
})
}
close (options?: CloseOptions) {
debug('closing project instance %s', this.projectRoot)
this.spec = null
this.browser = null
return Bluebird.join(
this.server?.close(),
this.watchers?.close(),
options?.onClose(),
)
.then(() => {
process.chdir(localCwd)
})
}
checkSupportFile (cfg) {
const supportFile = cfg.supportFile
if (supportFile) {
return fs.pathExists(supportFile)
.then((found) => {
if (!found) {
errors.throw('SUPPORT_FILE_NOT_FOUND', supportFile, settings.configFile(cfg))
}
})
}
}
watchPluginsFile (cfg, options) {
debug(`attempt watch plugins file: ${cfg.pluginsFile}`)
if (!cfg.pluginsFile || options.isTextTerminal) {
return Bluebird.resolve()
}
return fs.pathExists(cfg.pluginsFile)
.then((found) => {
debug(`plugins file found? ${found}`)
// ignore if not found. plugins#init will throw the right error
if (!found) {
return
}
debug('watch plugins file')
return this.watchers.watchTree(cfg.pluginsFile, {
onChange: () => {
// TODO: completely re-open project instead?
debug('plugins file changed')
// re-init plugins after a change
this._initPlugins(cfg, options)
.catch((err) => {
options.onError(err)
})
},
})
})
}
watchSettings (onSettingsChanged, options) {
// bail if we havent been told to
// watch anything (like in run mode)
if (!onSettingsChanged) {
return
}
debug('watch settings files')
const obj = {
onChange: () => {
// dont fire change events if we generated
// a project id less than 1 second ago
if (this.generatedProjectIdTimestamp &&
((Date.now() - this.generatedProjectIdTimestamp) < 1000)) {
return
}
// call our callback function
// when settings change!
onSettingsChanged.call(this)
},
}
if (options.configFile !== false) {
this.watchers.watch(settings.pathToConfigFile(this.projectRoot, options), obj)
}
return this.watchers.watch(settings.pathToCypressEnvJson(this.projectRoot), obj)
}
watchSettingsAndStartWebsockets (options: Record<string, unknown> = {}, cfg: Record<string, unknown> = {}) {
this.watchSettings(options.onSettingsChanged, options)
const { projectRoot } = cfg
let { reporter } = cfg as { reporter: RunnablesStore }
// if we've passed down reporter
// then record these via mocha reporter
if (cfg.report) {
try {
Reporter.loadReporter(reporter, projectRoot)
} catch (err) {
const paths = Reporter.getSearchPathsForReporter(reporter, projectRoot)
// only include the message if this is the standard MODULE_NOT_FOUND
// else include the whole stack
const errorMsg = err.code === 'MODULE_NOT_FOUND' ? err.message : err.stack
errors.throw('INVALID_REPORTER_NAME', {
paths,
error: errorMsg,
name: reporter,
})
}
reporter = Reporter.create(reporter, cfg.reporterOptions, projectRoot)
}
this._automation = new Automation(cfg.namespace, cfg.socketIoCookie, cfg.screenshotsFolder)
this.server.startWebsockets(this.automation, cfg, {
onReloadBrowser: options.onReloadBrowser,
onFocusTests: options.onFocusTests,
onSpecChanged: options.onSpecChanged,
onSavedStateChanged: options.onSavedStateChanged,
onCaptureVideoFrames: (data) => {
// TODO: move this to browser automation middleware
this.emit('capture:video:frames', data)
},
onConnect: (id) => {
this.emit('socket:connected', id)
},
onTestsReceivedAndMaybeRecord: async (runnables, cb) => {
debug('received runnables %o', runnables)
if (reporter != null) {
reporter.setRunnables(runnables)
}
if (this._recordTests) {
await this._recordTests(runnables, cb)
this._recordTests = null
return
}
cb()
},
onMocha: (event, runnable) => {
debug('onMocha', event)
// bail if we dont have a
// reporter instance
if (!reporter) {
return
}
reporter.emit(event, runnable)
if (event === 'end') {
return Bluebird.all([
(reporter != null ? reporter.end() : undefined),
this.server.end(),
])
.spread((stats = {}) => {
this.emit('end', stats)
})
}
},
})
}
changeToUrl (url) {
this.server.changeToUrl(url)
}
setCurrentSpecAndBrowser (spec, browser: Cypress.Browser) {
this.spec = spec
this.browser = browser
}
getCurrentSpecAndBrowser () {
return {
spec: this.spec,
browser: this.browser,
}
}
setBrowsers (browsers = []) {
debug('getting config before setting browsers %o', browsers)
return this.getConfig()
.then((cfg) => {
debug('setting config browsers to %o', browsers)
cfg.browsers = browsers
})
}
getAutomation () {
return this.automation
}
// do not check files again and again - keep previous promise
// to refresh it - just close and open the project again.
determineIsNewProject (folder) {
return scaffold.isNewProject(folder)
}
// returns project config (user settings + defaults + cypress.json)
// with additional object "state" which are transient things like
// window width and height, DevTools open or not, etc.
getConfig (options = {}): Bluebird<Cfg> {
if (options == null) {
options = this.options
}
if (this._cfg) {
debug('project has config %o', this._cfg)
return Bluebird.resolve(this._cfg)
}
const setNewProject = (cfg) => {
if (cfg.isTextTerminal) {
return
}
// decide if new project by asking scaffold
// and looking at previously saved user state
if (!cfg.integrationFolder) {
throw new Error('Missing integration folder')
}
return this.determineIsNewProject(cfg.integrationFolder)
.then((untouchedScaffold) => {
const userHasSeenOnBoarding = _.get(cfg, 'state.showedOnBoardingModal', false)
debugScaffold(`untouched scaffold ${untouchedScaffold} modal closed ${userHasSeenOnBoarding}`)
cfg.isNewProject = untouchedScaffold && !userHasSeenOnBoarding
})
}
return config.get(this.projectRoot, options)
.then((cfg) => {
return this._setSavedState(cfg)
})
.tap(setNewProject)
}
// forces saving of project's state by first merging with argument
saveState (stateChanges = {}) {
if (!this.cfg) {
throw new Error('Missing project config')
}
if (!this.projectRoot) {
throw new Error('Missing project root')
}
const newState = _.merge({}, this.cfg.state, stateChanges)
return savedState.create(this.projectRoot, this.cfg.isTextTerminal)
.then((state) => state.set(newState))
.then(() => {
this.cfg.state = newState
return newState
})
}
_setSavedState (cfg) {
debug('get saved state')
return savedState.create(this.projectRoot, cfg.isTextTerminal)
.then((state) => state.get())
.then((state) => {
cfg.state = state
return cfg
})
}
getSpecUrl (absoluteSpecPath, specType) {
debug('get spec url: %s for spec type %s', absoluteSpecPath, specType)
return this.getConfig()
.then((cfg) => {
// if we don't have a absoluteSpecPath or its __all
if (!absoluteSpecPath || (absoluteSpecPath === '__all')) {
const url = this.normalizeSpecUrl(cfg.browserUrl, '/__all')
debug('returning url to run all specs: %s', url)
return url
}
// TODO:
// to handle both unit + integration tests we need
// to figure out (based on the config) where this absoluteSpecPath
// lives. does it live in the integrationFolder or
// the unit folder?
// once we determine that we can then prefix it correctly
// with either integration or unit
const prefixedPath = this.getPrefixedPathToSpec(cfg, absoluteSpecPath, specType)
const url = this.normalizeSpecUrl(cfg.browserUrl, prefixedPath)
debug('return path to spec %o', { specType, absoluteSpecPath, prefixedPath, url })
return url
})
}
getPrefixedPathToSpec (cfg, pathToSpec, type = 'integration') {
const { integrationFolder, componentFolder, projectRoot } = cfg
// for now hard code the 'type' as integration
// but in the future accept something different here
// strip out the integration folder and prepend with "/"
// example:
//
// /Users/bmann/Dev/cypress-app/.projects/cypress/integration
// /Users/bmann/Dev/cypress-app/.projects/cypress/integration/foo.js
//
// becomes /integration/foo.js
const folderToUse = type === 'integration' ? integrationFolder : componentFolder
const url = `/${path.join(type, path.relative(
folderToUse,
path.resolve(projectRoot, pathToSpec),
))}`
debug('prefixed path for spec %o', { pathToSpec, type, url })
return url
}
normalizeSpecUrl (browserUrl, specUrl) {
const replacer = (match) => match.replace('//', '/')
return [
browserUrl,
'#/tests',
escapeFilenameInUrl(specUrl),
].join('/').replace(multipleForwardSlashesRe, replacer)
}
scaffold (cfg: Cfg) {
debug('scaffolding project %s', this.projectRoot)
const scaffolds = []
const push = scaffolds.push.bind(scaffolds)
// TODO: we are currently always scaffolding support
// even when headlessly - this is due to a major breaking
// change of 0.18.0
// we can later force this not to always happen when most
// of our users go beyond 0.18.0
//
// ensure support dir is created
// and example support file if dir doesnt exist
push(scaffold.support(cfg.supportFolder, cfg))
// if we're in headed mode add these other scaffolding tasks
debug('scaffold flags %o', {
isTextTerminal: cfg.isTextTerminal,
CYPRESS_INTERNAL_FORCE_SCAFFOLD: process.env.CYPRESS_INTERNAL_FORCE_SCAFFOLD,
})
const scaffoldExamples = !cfg.isTextTerminal || process.env.CYPRESS_INTERNAL_FORCE_SCAFFOLD
if (scaffoldExamples) {
debug('will scaffold integration and fixtures folder')
push(scaffold.integration(cfg.integrationFolder, cfg))
push(scaffold.fixture(cfg.fixturesFolder, cfg))
} else {
debug('will not scaffold integration or fixtures folder')
}
return Bluebird.all(scaffolds)
}
writeProjectId (id) {
const attrs = { projectId: id }
logger.info('Writing Project ID', _.clone(attrs))
this.generatedProjectIdTimestamp = new Date()
return settings
.write(this.projectRoot, attrs)
.return(id)
}
getProjectId () {
return this.verifyExistence()
.then(() => {
return settings.read(this.projectRoot, this.options)
})
.then((readSettings) => {
if (readSettings && readSettings.projectId) {
return readSettings.projectId
}
errors.throw('NO_PROJECT_ID', settings.configFile(this.options), this.projectRoot)
})
}
verifyExistence () {
return fs
.statAsync(this.projectRoot)
.return(this)
.catch(() => {
errors.throw('NO_PROJECT_FOUND_AT_PROJECT_ROOT', this.projectRoot)
})
}
createCiProject (projectDetails) {
debug('create CI project with projectDetails %o', projectDetails)
return user.ensureAuthToken()
.then((authToken) => {
const remoteOrigin = commitInfo.getRemoteOrigin(this.projectRoot)
debug('found remote origin at projectRoot %o', {
remoteOrigin,
projectRoot: this.projectRoot,
})
return remoteOrigin
.then((remoteOrigin) => {
return api.createProject(projectDetails, remoteOrigin, authToken)
})
}).then((newProject) => {
return this.writeProjectId(newProject.id)
.return(newProject)
})
}
getRecordKeys () {
return Bluebird.all([
this.getProjectId(),
user.ensureAuthToken(),
])
.spread((projectId, authToken) => {
return api.getProjectRecordKeys(projectId, authToken)
})
}
requestAccess (projectId) {
return user.ensureAuthToken()
.then((authToken) => {
return api.requestAccess(projectId, authToken)
})
}
static getOrgs () {
return user.ensureAuthToken()
.then((authToken) => {
return api.getOrgs(authToken)
})
}
static paths () {
return cache.getProjectRoots()
}
static getPathsAndIds () {
return cache.getProjectRoots()
// this assumes that the configFile for a cached project is 'cypress.json'
// https://git.io/JeGyF
.map((projectRoot) => {
return Bluebird.props({
path: projectRoot,
id: settings.id(projectRoot),
})
})
}
static getDashboardProjects () {
return user.ensureAuthToken()
.then((authToken) => {
debug('got auth token: %o', { authToken: keys.hide(authToken) })
return api.getProjects(authToken)
})
}
static _mergeDetails (clientProject, project) {
return _.extend({}, clientProject, project, { state: 'VALID' })
}
static _mergeState (clientProject, state) {
return _.extend({}, clientProject, { state })
}
static _getProject (clientProject, authToken) {
debug('get project from api', clientProject.id, clientProject.path)
return api.getProject(clientProject.id, authToken)
.then((project) => {
debug('got project from api')
return ProjectBase._mergeDetails(clientProject, project)
}).catch((err) => {
debug('failed to get project from api', err.statusCode)
switch (err.statusCode) {
case 404:
// project doesn't exist
return ProjectBase._mergeState(clientProject, 'INVALID')
case 403:
// project exists, but user isn't authorized for it
return ProjectBase._mergeState(clientProject, 'UNAUTHORIZED')
default:
throw err
}
})
}
static getProjectStatuses (clientProjects = []) {
debug(`get project statuses for ${clientProjects.length} projects`)
return user.ensureAuthToken()
.then((authToken) => {
debug('got auth token: %o', { authToken: keys.hide(authToken) })
return api.getProjects(authToken).then((projects = []) => {
debug(`got ${projects.length} projects`)
const projectsIndex = _.keyBy(projects, 'id')
return Bluebird.all(_.map(clientProjects, (clientProject) => {
debug('looking at', clientProject.path)
// not a CI project, just mark as valid and return
if (!clientProject.id) {
debug('no project id')
return ProjectBase._mergeState(clientProject, 'VALID')
}
const project = projectsIndex[clientProject.id]
if (project) {
debug('found matching:', project)
// merge in details for matching project
return ProjectBase._mergeDetails(clientProject, project)
}
debug('did not find matching:', project)
// project has id, but no matching project found
// check if it doesn't exist or if user isn't authorized
return ProjectBase._getProject(clientProject, authToken)
}))
})
})
}
static getProjectStatus (clientProject) {
debug('get project status for client id %s at path %s', clientProject.id, clientProject.path)
if (!clientProject.id) {
debug('no project id')
return Bluebird.resolve(ProjectBase._mergeState(clientProject, 'VALID'))
}
return user.ensureAuthToken().then((authToken) => {
debug('got auth token: %o', { authToken: keys.hide(authToken) })
return ProjectBase._getProject(clientProject, authToken)
})
}
static remove (path) {
return cache.removeProject(path)
}
static add (path, options) {
// don't cache a project if a non-default configFile is set
// https://git.io/JeGyF
if (settings.configFile(options) !== 'cypress.json') {
return Bluebird.resolve({ path })
}
return cache.insertProject(path)
.then(() => {
return this.id(path)
}).then((id) => {
return { id, path }
})
.catch(() => {
return { path }
})
}
static id (path) {
return new ProjectBase(path).getProjectId()
}
static ensureExists (path, options) {
// is there a configFile? is the root writable?
return settings.exists(path, options)
}
static config (path) {
return new ProjectBase(path).getConfig()
}
static getSecretKeyByPath (path) {
// get project id
return ProjectBase.id(path)
.then((id) => {
return user.ensureAuthToken()
.then((authToken) => {
return api.getProjectToken(id, authToken)
.catch(() => {
errors.throw('CANNOT_FETCH_PROJECT_TOKEN')
})
})
})
}
static generateSecretKeyByPath (path) {
// get project id
return ProjectBase.id(path)
.then((id) => {
return user.ensureAuthToken()
.then((authToken) => {
return api.updateProjectToken(id, authToken)
.catch(() => {
errors.throw('CANNOT_CREATE_PROJECT_TOKEN')
})
})
})
}
// Given a path to the project, finds all specs
// returns list of specs with respect to the project root
static findSpecs (projectRoot, specPattern) {
debug('finding specs for project %s', projectRoot)
la(check.unemptyString(projectRoot), 'missing project path', projectRoot)
la(check.maybe.unemptyString(specPattern), 'invalid spec pattern', specPattern)
// if we have a spec pattern
if (specPattern) {
// then normalize to create an absolute
// file path from projectRoot
// ie: **/* turns into /Users/bmann/dev/project/**/*
specPattern = path.resolve(projectRoot, specPattern)
debug('full spec pattern "%s"', specPattern)
}
return new ProjectBase(projectRoot)
.getConfig()
// TODO: handle wild card pattern or spec filename
.then((cfg) => {
return specsUtil.find(cfg, specPattern)
}).then(R.prop('integration'))
.then(R.map(R.prop('name')))
}
}