Files
cypress/packages/server/lib/project.coffee
T
2017-07-07 10:08:54 -04:00

495 lines
14 KiB
CoffeeScript

_ = require("lodash")
fs = require("fs-extra")
EE = require("events")
path = require("path")
glob = require("glob")
Promise = require("bluebird")
cwd = require("./cwd")
ids = require("./ids")
api = require("./api")
user = require("./user")
cache = require("./cache")
config = require("./config")
logger = require("./logger")
debug = require('./log')
errors = require("./errors")
Server = require("./server")
scaffold = require("./scaffold")
Watchers = require("./watchers")
Reporter = require("./reporter")
savedState = require("./saved_state")
Automation = require("./automation")
git = require("./util/git")
settings = require("./util/settings")
scaffoldLog = require("debug")("cypress:server:scaffold")
fs = Promise.promisifyAll(fs)
glob = Promise.promisify(glob)
localCwd = cwd()
multipleForwardSlashesRe = /[^:\/\/](\/{2,})/g
class Project extends EE
constructor: (projectRoot) ->
if not (@ instanceof Project)
return new Project(projectRoot)
if not projectRoot
throw new Error("Instantiating lib/project requires a projectRoot!")
@projectRoot = path.resolve(projectRoot)
@watchers = Watchers()
@server = null
@cfg = null
@memoryCheck = null
@automation = null
debug("Project created %s", @projectRoot)
open: (options = {}) ->
debug("opening project instance %s", @projectRoot)
@server = Server(@watchers)
_.defaults options, {
report: false
onFocusTests: ->
onSettingsChanged: false
}
if process.env.CYPRESS_MEMORY
log = ->
console.log("memory info", process.memoryUsage())
@memoryCheck = setInterval(log, 1000)
@getConfig(options)
.then (cfg) =>
process.chdir(@projectRoot)
@server.open(cfg, @)
.spread (port, warning) =>
## if we didnt have a cfg.port
## then get the port once we
## open the server
if not cfg.port
cfg.port = port
## and set all the urls again
_.extend cfg, config.setUrls(cfg)
## store the cfg from
## opening the server
@cfg = cfg
if warning
options.onWarning(warning)
options.onSavedStateChanged = (state) =>
@saveState(state)
Promise.join(
@watchSettingsAndStartWebsockets(options, cfg)
@scaffold(cfg)
)
.then =>
@watchSupportFile(cfg)
# return our project instance
.return(@)
getRuns: ->
Promise.all([
@getProjectId(),
user.ensureAuthToken()
])
.spread (projectId, authToken) ->
api.getProjectRuns(projectId, authToken)
close: ->
debug("closing project instance %s", @projectRoot)
if @memoryCheck
clearInterval(@memoryCheck)
@cfg = null
Promise.join(
@server?.close(),
@watchers?.close()
)
.then ->
process.chdir(localCwd)
watchSupportFile: (config) ->
if supportFile = config.supportFile
relativePath = path.relative(config.projectRoot, config.supportFile)
if config.watchForFileChanges isnt false
options = {
onChange: _.bind(@server.onTestFileChange, @server, relativePath)
}
@watchers.watchBundle(relativePath, config, options)
## ignore errors b/c we're just setting up the watching. errors
## are handled by the spec controller
.catch ->
watchSettings: (onSettingsChanged) ->
## bail if we havent been told to
## watch anything
return if not onSettingsChanged
obj = {
onChange: (filePath, stats) =>
## dont fire change events if we generated
## a project id less than 1 second ago
return if @generatedProjectIdTimestamp and
(new Date - @generatedProjectIdTimestamp) < 1000
## call our callback function
## when settings change!
onSettingsChanged.call(@)
}
@watchers.watch(settings.pathToCypressJson(@projectRoot), obj)
watchSettingsAndStartWebsockets: (options = {}, config = {}) ->
@watchSettings(options.onSettingsChanged)
## if we've passed down reporter
## then record these via mocha reporter
if config.report
reporter = Reporter.create(config.reporter, config.reporterOptions, config.projectRoot)
@automation = Automation.create(config.namespace, config.socketIoCookie, config.screenshotsFolder)
@server.startWebsockets(@watchers, @automation, config, {
onReloadBrowser: options.onReloadBrowser
onFocusTests: options.onFocusTests
onSpecChanged: options.onSpecChanged
onSavedStateChanged: options.onSavedStateChanged
onConnect: (id) =>
@emit("socket:connected", id)
onSetRunnables: (runnables) ->
reporter?.setRunnables(runnables)
onMocha: (event, runnable) =>
## bail if we dont have a
## reporter instance
return if not reporter
reporter.emit(event, runnable)
if event is "end"
stats = reporter.stats()
## TODO: convert this to a promise
## since we need an ack to this end
## event, and then finally emit 'end'
@server.end()
@emit("end", stats)
})
changeToUrl: (url) ->
@server.changeToUrl(url)
setBrowsers: (browsers = []) ->
@getConfig()
.then (cfg) ->
cfg.browsers = browsers
getAutomation: ->
@automation
## do not check files again and again - keep previous promise
## to refresh it - just close and open the project again.
determineIsNewProject: (folder) ->
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 = {}) =>
setNewProject = (cfg) =>
## decide if new project by asking scaffold
## and looking at previously saved user state
throw new Error("Missing integration folder") if not cfg.integrationFolder
@determineIsNewProject(cfg.integrationFolder)
.then (untouchedScaffold) ->
userHasSeenOnBoarding = _.get(cfg, 'state.showedOnBoardingModal', false)
scaffoldLog "untouched scaffold #{untouchedScaffold} modal closed #{userHasSeenOnBoarding}"
cfg.isNewProject = untouchedScaffold && !userHasSeenOnBoarding
.return(cfg)
if c = @cfg
Promise.resolve(c)
else
config.get(@projectRoot, options)
.then (cfg) => @_setSavedState(cfg)
.then(setNewProject)
# forces saving of project's state by first merging with argument
saveState: (stateChanges = {}) ->
throw new Error("Missing project config") if not @cfg
throw new Error("Missing project root") if not @projectRoot
newState = _.merge({}, @cfg.state, stateChanges)
savedState(@projectRoot).set(newState)
.then =>
@cfg.state = newState
newState
_setSavedState: (cfg) ->
savedState(@projectRoot).get()
.then (state) ->
cfg.state = state
cfg
ensureSpecUrl: (spec) ->
@getConfig()
.then (cfg) =>
## if we dont have a spec or its __all
if not spec or (spec is "__all")
@getUrlBySpec(cfg.browserUrl, "/__all")
else
@ensureSpecExists(spec)
.then (pathToSpec) =>
## TODO:
## to handle both unit + integration tests we need
## to figure out (based on the config) where this spec
## 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
prefixedPath = @getPrefixedPathToSpec(cfg.integrationFolder, pathToSpec)
@getUrlBySpec(cfg.browserUrl, prefixedPath)
ensureSpecExists: (spec) ->
specFile = path.resolve(@projectRoot, spec)
## we want to make it easy on the user by allowing them to pass both
## an absolute path to the spec, or a relative path from their test folder
fs
.statAsync(specFile)
.return(specFile)
.catch ->
errors.throw("SPEC_FILE_NOT_FOUND", specFile)
getPrefixedPathToSpec: (integrationFolder, pathToSpec, type = "integration") ->
## 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.coffee
##
## becomes /integration/foo.coffee
"/" + path.join(type, path.relative(integrationFolder, pathToSpec))
getUrlBySpec: (browserUrl, specUrl) ->
replacer = (match, p1) ->
match.replace("//", "/")
[browserUrl, "#/tests", specUrl].join("/").replace(multipleForwardSlashesRe, replacer)
scaffold: (config) ->
debug("scaffolding project %s", @projectRoot)
scaffolds = []
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(config.supportFolder, config))
## if we're in headed mode add these other scaffolding
## tasks
if not config.isHeadless
## ensure integration folder is created
## and example spec if dir doesnt exit
push(scaffold.integration(config.integrationFolder, config))
## ensure fixtures dir is created
## and example fixture if dir doesnt exist
push(scaffold.fixture(config.fixturesFolder, config))
Promise.all(scaffolds)
writeProjectId: (id) ->
attrs = {projectId: id}
logger.info "Writing Project ID", _.clone(attrs)
@generatedProjectIdTimestamp = new Date
settings
.write(@projectRoot, attrs)
.return(id)
getProjectId: ->
@verifyExistence()
.then =>
if id = process.env.CYPRESS_PROJECT_ID
{projectId: id}
else
settings.read(@projectRoot)
.then (settings) =>
if settings and id = settings.projectId
return id
errors.throw("NO_PROJECT_ID", @projectRoot)
verifyExistence: ->
fs
.statAsync(@projectRoot)
.return(@)
.catch =>
errors.throw("NO_PROJECT_FOUND_AT_PROJECT_ROOT", @projectRoot)
createCiProject: (projectDetails) ->
user.ensureAuthToken()
.then (authToken) =>
git
.init(@projectRoot)
.getRemoteOrigin()
.then (remoteOrigin) ->
api.createProject(projectDetails, remoteOrigin, authToken)
.then (newProject) =>
@writeProjectId(newProject.id)
.return(newProject)
getRecordKeys: ->
Promise.all([
@getProjectId(),
user.ensureAuthToken()
])
.spread (projectId, authToken) ->
api.getProjectRecordKeys(projectId, authToken)
requestAccess: (projectId) ->
user.ensureAuthToken()
.then (authToken) ->
api.requestAccess(projectId, authToken)
@getOrgs = ->
user.ensureAuthToken()
.then (authToken) ->
api.getOrgs(authToken)
@paths = ->
cache.getProjectPaths()
@getPathsAndIds = ->
cache.getProjectPaths()
.map (projectPath) ->
Promise.props({
path: projectPath
id: settings.id(projectPath)
})
@_mergeDetails = (clientProject, project) ->
_.extend({}, clientProject, project, {state: "VALID"})
@_mergeState = (clientProject, state) ->
_.extend({}, clientProject, {state: state})
@_getProject = (clientProject, authToken) ->
api.getProject(clientProject.id, authToken)
.then (project) ->
Project._mergeDetails(clientProject, project)
.catch (err) ->
switch err.statusCode
when 404
## project doesn't exist
return Project._mergeState(clientProject, "INVALID")
when 403
## project exists, but user isn't authorized for it
return Project._mergeState(clientProject, "UNAUTHORIZED")
else
throw err
@getProjectStatuses = (clientProjects = []) ->
user.ensureAuthToken()
.then (authToken) ->
api.getProjects(authToken).then (projects = []) ->
projectsIndex = _.keyBy(projects, "id")
Promise.all(_.map clientProjects, (clientProject) ->
## not a CI project, just mark as valid and return
if not clientProject.id
return Project._mergeState(clientProject, "VALID")
if project = projectsIndex[clientProject.id]
## merge in details for matching project
return Project._mergeDetails(clientProject, project)
else
## project has id, but no matching project found
## check if it doesn't exist or if user isn't authorized
Project._getProject(clientProject, authToken)
)
@getProjectStatus = (clientProject) ->
user.ensureAuthToken().then (authToken) ->
Project._getProject(clientProject, authToken)
@remove = (path) ->
cache.removeProject(path)
@add = (path) ->
cache.insertProject(path)
.then =>
@id(path)
.then (id) ->
{id, path}
.catch ->
{path}
@removeIds = (p) ->
Project(p)
.verifyExistence()
.call("getConfig")
.then (cfg) ->
## remove all of the ids for the test files found in the integrationFolder
ids.remove(cfg.integrationFolder)
@id = (path) ->
Project(path).getProjectId()
@exists = (path) ->
@paths().then (paths) ->
path in paths
@config = (path) ->
Project(path).getConfig()
@getSecretKeyByPath = (path) ->
## get project id
Project.id(path)
.then (id) ->
user.ensureAuthToken()
.then (authToken) ->
api.getProjectToken(id, authToken)
.catch ->
errors.throw("CANNOT_FETCH_PROJECT_TOKEN")
@generateSecretKeyByPath = (path) ->
## get project id
Project.id(path)
.then (id) ->
user.ensureAuthToken()
.then (authToken) ->
api.updateProjectToken(id, authToken)
.catch ->
errors.throw("CANNOT_CREATE_PROJECT_TOKEN")
module.exports = Project